diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index 4aefc80cb..e294e972c 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -70,6 +70,26 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 + # Real Postgres for the integration tests that need row locks and + # `ON CONFLICT` (notifications). Each test file creates its own database. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: root + POSTGRES_PASSWORD: root + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U root" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + VITNODE_TEST_POSTGRES_URL: postgresql://root:root@localhost:5432/postgres + steps: - name: Checkout uses: actions/checkout@v7 diff --git a/apps/api/migrations/20261003201043_blog_post_excerpt/migration.sql b/apps/api/migrations/20261003201043_blog_post_excerpt/migration.sql new file mode 100644 index 000000000..ad38d256f --- /dev/null +++ b/apps/api/migrations/20261003201043_blog_post_excerpt/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "blog_posts_translations" ADD COLUMN "excerpt" text; \ No newline at end of file diff --git a/apps/api/migrations/20261003201043_blog_post_excerpt/snapshot.json b/apps/api/migrations/20261003201043_blog_post_excerpt/snapshot.json new file mode 100644 index 000000000..73313ea5b --- /dev/null +++ b/apps/api/migrations/20261003201043_blog_post_excerpt/snapshot.json @@ -0,0 +1,9644 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "bb799038-c9f7-4c6a-83ac-68898c684511", + "prevIds": [ + "0663358e-88f9-4f61-9a6d-14443d4c0d92" + ], + "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_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_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_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "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": "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": "integer", + "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": "integer", + "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": "integer", + "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": "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_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_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "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": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "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": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "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": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": 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": [ + "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": [ + "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": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_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": "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": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_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": [ + "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_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_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "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": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/api/migrations/20261004081732_add_core_notifications/migration.sql b/apps/api/migrations/20261004081732_add_core_notifications/migration.sql new file mode 100644 index 000000000..7fb1da96f --- /dev/null +++ b/apps/api/migrations/20261004081732_add_core_notifications/migration.sql @@ -0,0 +1,137 @@ +CREATE TABLE "core_notification_deliveries" ( + "id" bigserial PRIMARY KEY, + "userId" integer NOT NULL, + "channel" varchar(20) NOT NULL, + "mode" varchar(20) NOT NULL, + "eventId" bigint, + "idempotencyKey" varchar(255) NOT NULL UNIQUE, + "status" varchar(20) DEFAULT 'pending' NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "maxAttempts" integer DEFAULT 5 NOT NULL, + "availableAt" timestamp DEFAULT now() NOT NULL, + "periodStart" timestamp, + "periodEnd" timestamp, + "itemCount" integer DEFAULT 0 NOT NULL, + "providerMessageId" varchar(255), + "lastError" varchar(500), + "skipReason" varchar(50), + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "sentAt" timestamp +); +--> statement-breakpoint +ALTER TABLE "core_notification_deliveries" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_notification_events" ( + "id" bigserial PRIMARY KEY, + "pluginId" varchar(100) NOT NULL, + "type" varchar(100) NOT NULL, + "schemaVersion" integer DEFAULT 1 NOT NULL, + "idempotencyKey" varchar(255) NOT NULL, + "actorId" integer, + "subjectType" varchar(100), + "subjectId" varchar(100), + "groupKey" varchar(255), + "data" jsonb DEFAULT '{}' NOT NULL, + "recipientIds" integer[] DEFAULT '{}'::integer[] NOT NULL, + "followersOf" jsonb DEFAULT '[]' NOT NULL, + "allowSelf" boolean DEFAULT false NOT NULL, + "status" varchar(20) DEFAULT 'pending' NOT NULL, + "recipientCursor" integer DEFAULT 0 NOT NULL, + "followerSubjectCursor" integer DEFAULT 0 NOT NULL, + "followerUserCursor" integer DEFAULT 0 NOT NULL, + "deliveredCount" integer DEFAULT 0 NOT NULL, + "lastError" text, + "createdAt" timestamp DEFAULT now() NOT NULL, + "completedAt" timestamp +); +--> statement-breakpoint +ALTER TABLE "core_notification_events" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_notification_receipts" ( + "eventId" bigint, + "userId" integer, + "notificationId" bigint, + "seq" integer, + "emailPending" boolean DEFAULT false NOT NULL, + "emailDeliveryId" bigint, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "core_notification_receipts_pkey" PRIMARY KEY("eventId","userId") +); +--> statement-breakpoint +ALTER TABLE "core_notification_receipts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_notification_settings" ( + "key" varchar(255) PRIMARY KEY, + "value" jsonb DEFAULT '{}' NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_notification_settings" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_notification_subscriptions" ( + "userId" integer, + "subjectType" varchar(100), + "subjectId" varchar(100), + "state" varchar(20) NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "core_notification_subscriptions_pkey" PRIMARY KEY("userId","subjectType","subjectId") +); +--> statement-breakpoint +ALTER TABLE "core_notification_subscriptions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_notification_user_state" ( + "userId" integer PRIMARY KEY, + "unreadCount" integer DEFAULT 0 NOT NULL, + "revision" bigint DEFAULT 0 NOT NULL, + "timeZone" varchar(64), + "digestHour" smallint DEFAULT 8 NOT NULL, + "digestWeekday" smallint DEFAULT 1 NOT NULL, + "preferences" jsonb DEFAULT '{}' NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_notification_user_state" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_notifications" ( + "id" bigserial PRIMARY KEY, + "userId" integer NOT NULL, + "pluginId" varchar(100) NOT NULL, + "type" varchar(100) NOT NULL, + "category" varchar(50) NOT NULL, + "groupKey" varchar(255) NOT NULL, + "groupBucket" bigint DEFAULT 0 NOT NULL, + "subjectType" varchar(100), + "subjectId" varchar(100), + "latestEventId" bigint NOT NULL, + "eventCount" integer DEFAULT 1 NOT NULL, + "activitySeq" integer DEFAULT 1 NOT NULL, + "readSeq" integer DEFAULT 0 NOT NULL, + "readAt" timestamp, + "archivedAt" timestamp, + "createdAt" timestamp DEFAULT now() NOT NULL, + "lastActivityAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_notifications" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE INDEX "core_notification_deliveries_claim_idx" ON "core_notification_deliveries" ("availableAt","id") WHERE status = 'pending';--> statement-breakpoint +CREATE INDEX "core_notification_deliveries_status_idx" ON "core_notification_deliveries" ("status","id");--> statement-breakpoint +CREATE INDEX "core_notification_deliveries_user_idx" ON "core_notification_deliveries" ("userId");--> statement-breakpoint +CREATE UNIQUE INDEX "core_notification_events_idempotency_unique" ON "core_notification_events" ("pluginId","type","idempotencyKey");--> statement-breakpoint +CREATE INDEX "core_notification_events_status_idx" ON "core_notification_events" ("status","id");--> statement-breakpoint +CREATE INDEX "core_notification_events_created_at_idx" ON "core_notification_events" ("createdAt");--> statement-breakpoint +CREATE INDEX "core_notification_receipts_notification_idx" ON "core_notification_receipts" ("notificationId","seq");--> statement-breakpoint +CREATE INDEX "core_notification_receipts_user_idx" ON "core_notification_receipts" ("userId");--> statement-breakpoint +CREATE INDEX "core_notification_receipts_email_pending_idx" ON "core_notification_receipts" ("userId","createdAt") WHERE "emailPending" = true AND "emailDeliveryId" IS NULL;--> statement-breakpoint +CREATE INDEX "core_notification_subscriptions_subject_idx" ON "core_notification_subscriptions" ("subjectType","subjectId","state","userId");--> statement-breakpoint +CREATE INDEX "core_notification_user_state_unread_idx" ON "core_notification_user_state" ("unreadCount");--> statement-breakpoint +CREATE UNIQUE INDEX "core_notifications_group_unique" ON "core_notifications" ("userId","pluginId","type","groupKey","groupBucket");--> statement-breakpoint +CREATE INDEX "core_notifications_inbox_idx" ON "core_notifications" ("userId","lastActivityAt" DESC NULLS LAST,"id" DESC NULLS LAST) WHERE "archivedAt" IS NULL;--> statement-breakpoint +CREATE INDEX "core_notifications_unread_idx" ON "core_notifications" ("userId","lastActivityAt" DESC NULLS LAST,"id" DESC NULLS LAST) WHERE "archivedAt" IS NULL AND "readSeq" < "activitySeq";--> statement-breakpoint +CREATE INDEX "core_notifications_subject_idx" ON "core_notifications" ("subjectType","subjectId");--> statement-breakpoint +CREATE INDEX "core_notifications_last_activity_idx" ON "core_notifications" ("lastActivityAt");--> statement-breakpoint +CREATE INDEX "core_notifications_latest_event_idx" ON "core_notifications" ("latestEventId");--> statement-breakpoint +ALTER TABLE "core_notification_deliveries" ADD CONSTRAINT "core_notification_deliveries_userId_core_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "core_users"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_notification_deliveries" ADD CONSTRAINT "core_notification_deliveries_vcf37BytIa0M_fkey" FOREIGN KEY ("eventId") REFERENCES "core_notification_events"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_notification_events" ADD CONSTRAINT "core_notification_events_actorId_core_users_id_fkey" FOREIGN KEY ("actorId") REFERENCES "core_users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_notification_receipts" ADD CONSTRAINT "core_notification_receipts_UQ7ZEs0I6ijx_fkey" FOREIGN KEY ("eventId") REFERENCES "core_notification_events"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_notification_receipts" ADD CONSTRAINT "core_notification_receipts_userId_core_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "core_users"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_notification_receipts" ADD CONSTRAINT "core_notification_receipts_Vxye2Cs6MgcL_fkey" FOREIGN KEY ("notificationId") REFERENCES "core_notifications"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_notification_subscriptions" ADD CONSTRAINT "core_notification_subscriptions_userId_core_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "core_users"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_notification_user_state" ADD CONSTRAINT "core_notification_user_state_userId_core_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "core_users"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_notifications" ADD CONSTRAINT "core_notifications_userId_core_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "core_users"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_notifications" ADD CONSTRAINT "core_notifications_I8QoGbTal5Wt_fkey" FOREIGN KEY ("latestEventId") REFERENCES "core_notification_events"("id") ON DELETE RESTRICT; \ No newline at end of file diff --git a/apps/api/migrations/20261004081732_add_core_notifications/snapshot.json b/apps/api/migrations/20261004081732_add_core_notifications/snapshot.json new file mode 100644 index 000000000..ae8847204 --- /dev/null +++ b/apps/api/migrations/20261004081732_add_core_notifications/snapshot.json @@ -0,0 +1,11491 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "15b0095b-281b-4385-a618-2208244a53f7", + "prevIds": [ + "0c4efb07-e5e5-4e06-b87e-88d08c84a3a8" + ], + "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_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_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_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_subscriptions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "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": "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": "integer", + "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": "integer", + "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": "integer", + "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": "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_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": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "test123", + "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_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "followersOf", + "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": "followerSubjectCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followerUserCursor", + "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_subscriptions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "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": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "smallint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "8", + "generated": null, + "identity": null, + "name": "digestHour", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "smallint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "digestWeekday", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "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": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "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": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "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": "token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_token_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "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": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "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": "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": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "state", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_subscriptions_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "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": "nameCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_email_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "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": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": 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": [ + "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": [ + "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": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_subscriptions_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "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": "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": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "subjectType", + "subjectId" + ], + "nameExplicit": false, + "name": "core_notification_subscriptions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_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": [ + "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_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_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "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": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/api/migrations/20261004112416_allow_null_notification_digest_schedule/migration.sql b/apps/api/migrations/20261004112416_allow_null_notification_digest_schedule/migration.sql new file mode 100644 index 000000000..c894e6730 --- /dev/null +++ b/apps/api/migrations/20261004112416_allow_null_notification_digest_schedule/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "core_notification_user_state" ALTER COLUMN "digestHour" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "core_notification_user_state" ALTER COLUMN "digestHour" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "core_notification_user_state" ALTER COLUMN "digestWeekday" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "core_notification_user_state" ALTER COLUMN "digestWeekday" DROP NOT NULL;--> statement-breakpoint +UPDATE "core_notification_user_state" SET "digestHour" = NULL, "digestWeekday" = NULL WHERE "digestHour" = 8 AND "digestWeekday" = 1; diff --git a/apps/api/migrations/20261004112416_allow_null_notification_digest_schedule/snapshot.json b/apps/api/migrations/20261004112416_allow_null_notification_digest_schedule/snapshot.json new file mode 100644 index 000000000..2f211761d --- /dev/null +++ b/apps/api/migrations/20261004112416_allow_null_notification_digest_schedule/snapshot.json @@ -0,0 +1,11491 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "96777a97-258a-46b1-ac80-a6f1b19e32ed", + "prevIds": [ + "15b0095b-281b-4385-a618-2208244a53f7" + ], + "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_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_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_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_subscriptions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "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": "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": "integer", + "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": "integer", + "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": "integer", + "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": "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_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": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "test123", + "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_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "followersOf", + "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": "followerSubjectCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followerUserCursor", + "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_subscriptions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "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": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "smallint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "digestHour", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "smallint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "digestWeekday", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "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": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "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": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "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": "token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_token_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "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": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "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": "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": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "state", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_subscriptions_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "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": "nameCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_email_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "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": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": 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": [ + "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": [ + "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": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_subscriptions_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "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": "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": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "subjectType", + "subjectId" + ], + "nameExplicit": false, + "name": "core_notification_subscriptions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_subscriptions" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_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": [ + "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_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_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "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": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/api/migrations/20261004130641_remove_notification_follows_move_time_zone/migration.sql b/apps/api/migrations/20261004130641_remove_notification_follows_move_time_zone/migration.sql new file mode 100644 index 000000000..8d66cc37b --- /dev/null +++ b/apps/api/migrations/20261004130641_remove_notification_follows_move_time_zone/migration.sql @@ -0,0 +1,7 @@ +DROP TABLE "core_notification_subscriptions";--> statement-breakpoint +ALTER TABLE "core_users" ADD COLUMN "timeZone" varchar(64);--> statement-breakpoint +UPDATE "core_users" AS u SET "timeZone" = s."timeZone" FROM "core_notification_user_state" AS s WHERE s."userId" = u."id" AND s."timeZone" IS NOT NULL;--> statement-breakpoint +ALTER TABLE "core_notification_events" DROP COLUMN "followersOf";--> statement-breakpoint +ALTER TABLE "core_notification_events" DROP COLUMN "followerSubjectCursor";--> statement-breakpoint +ALTER TABLE "core_notification_events" DROP COLUMN "followerUserCursor";--> statement-breakpoint +ALTER TABLE "core_notification_user_state" DROP COLUMN "timeZone"; \ No newline at end of file diff --git a/apps/api/migrations/20261004130641_remove_notification_follows_move_time_zone/snapshot.json b/apps/api/migrations/20261004130641_remove_notification_follows_move_time_zone/snapshot.json new file mode 100644 index 000000000..ddeb4f4a9 --- /dev/null +++ b/apps/api/migrations/20261004130641_remove_notification_follows_move_time_zone/snapshot.json @@ -0,0 +1,11310 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "0ff9baa5-66a0-4397-a25d-dde7167fcbbe", + "prevIds": [ + "96777a97-258a-46b1-ac80-a6f1b19e32ed" + ], + "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_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_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_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "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": "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": "integer", + "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": "integer", + "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": "integer", + "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": "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_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": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "test123", + "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_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "smallint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "digestHour", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "smallint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "digestWeekday", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "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": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "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": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "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": "token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_token_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "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": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "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": "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": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "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": "nameCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_email_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "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": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": 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": [ + "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": [ + "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": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "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": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_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": [ + "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_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_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "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": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/api/migrations/20261004144103_remove_notification_digest_schedule/migration.sql b/apps/api/migrations/20261004144103_remove_notification_digest_schedule/migration.sql new file mode 100644 index 000000000..783df960e --- /dev/null +++ b/apps/api/migrations/20261004144103_remove_notification_digest_schedule/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "core_notification_user_state" DROP COLUMN "digestHour";--> statement-breakpoint +ALTER TABLE "core_notification_user_state" DROP COLUMN "digestWeekday";--> statement-breakpoint +UPDATE "core_notification_settings" SET "value" = "value" - 'digestHour' - 'digestWeekday' WHERE "key" = 'global'; diff --git a/apps/api/migrations/20261004144103_remove_notification_digest_schedule/snapshot.json b/apps/api/migrations/20261004144103_remove_notification_digest_schedule/snapshot.json new file mode 100644 index 000000000..825fc0012 --- /dev/null +++ b/apps/api/migrations/20261004144103_remove_notification_digest_schedule/snapshot.json @@ -0,0 +1,11284 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "5fb751b5-4856-4019-9249-b28a868bfefe", + "prevIds": [ + "0ff9baa5-66a0-4397-a25d-dde7167fcbbe" + ], + "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_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_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_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "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": "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": "integer", + "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": "integer", + "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": "integer", + "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": "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_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": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "test123", + "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_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "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": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "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": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "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": "token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_token_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "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": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "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": "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": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "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": "nameCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_email_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "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": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": 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": [ + "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": [ + "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": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "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": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_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": [ + "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_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_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "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": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/api/migrations/20261004145703_remove_notification_email_settings/migration.sql b/apps/api/migrations/20261004145703_remove_notification_email_settings/migration.sql new file mode 100644 index 000000000..d5f330dbd --- /dev/null +++ b/apps/api/migrations/20261004145703_remove_notification_email_settings/migration.sql @@ -0,0 +1 @@ +UPDATE "core_notification_settings" SET "value" = "value" - 'emailEnabled' - 'emailCapPerHour' WHERE "key" = 'global'; diff --git a/apps/api/migrations/20261004145703_remove_notification_email_settings/snapshot.json b/apps/api/migrations/20261004145703_remove_notification_email_settings/snapshot.json new file mode 100644 index 000000000..a8c093bce --- /dev/null +++ b/apps/api/migrations/20261004145703_remove_notification_email_settings/snapshot.json @@ -0,0 +1,11284 @@ +{ + "id": "5fef0d7f-7907-4111-9e85-790f416a05ff", + "prevIds": [ + "5fb751b5-4856-4019-9249-b28a868bfefe" + ], + "version": "8", + "dialect": "postgres", + "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_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_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_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "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": "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": "integer", + "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": "integer", + "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": "integer", + "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": "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_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": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "test123", + "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_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "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": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "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": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "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": "token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_token_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "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": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "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": "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": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "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": "nameCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_email_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "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": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": 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": [ + "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": [ + "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": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "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": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_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": [ + "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_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_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "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": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/web/content/docs/dev/advanced/queue.mdx b/apps/web/content/docs/dev/advanced/queue.mdx index 08a6f305d..94b28f101 100644 --- a/apps/web/content/docs/dev/advanced/queue.mdx +++ b/apps/web/content/docs/dev/advanced/queue.mdx @@ -86,11 +86,19 @@ export const riskyTask = buildQueueTask({ | Attempt | Delay Before Next Retry | | :--- | :--- | -| 1st failure | ~1 minute | -| 2nd failure | ~4 minutes | -| 3rd failure | ~9 minutes | +| 1st failure | ~10 seconds | +| 2nd failure | ~20 seconds | +| Each next failure | Doubles, capped at 1 hour | | Final failure | Marked as `failed` in `core_queue` | +### Stale Tasks Are Reclaimed + +A task still `processing` after 15 minutes (`QUEUE_STALE_PROCESSING_MINUTES`) belonged to a worker that died mid-run. The queue worker puts it back to `pending`, or marks it `failed` if it has used every attempt, so a crash never strands a task forever. + + + A handler that runs longer would be reclaimed and run again while it is still going. Long work should save its progress and dispatch a follow-up task instead - the way [notification fan-out](/docs/dev/notifications/queue-and-scale) does. + + --- ## AdminCP Queue Monitor @@ -128,6 +136,11 @@ Inspect and debug tasks at **Core → Advanced → Queue** (`/admin/core/advance ## Learn More + Promise + idempotencyKey?: string + }) => Promise +} + +export interface EmailSendResult { + id?: string // the provider's message id } ``` @@ -119,9 +124,31 @@ export const vitNodeApiConfig = buildApiConfig({ description: "Optional reply-to email address.", type: "string", }, + idempotencyKey: { + description: "Stable per logical email, the same on every retry. Forward it if your provider deduplicates on a key; ignore it otherwise.", + type: "string", + }, }} /> +## Idempotency and the Return Value + +Callers such as [notification emails](/docs/dev/notifications/email-and-digests) retry a send that failed or never got an answer, with the same `idempotencyKey`. Forward it to a provider that deduplicates on a key, and a retry after an accepted-but-unconfirmed send is dropped instead of delivered twice. Resend does this; SMTP cannot, so Nodemailer ignores the key. + +Return `{ id }` with the provider's message id when you have one - it shows up in the AdminCP delivery list. Returning nothing is still valid. + +```ts +sendEmail: async ({ to, subject, html, text, metadata, idempotencyKey }) => { + const res = await fetch("https://api.postmarkapp.com/email", { + /* ...as above, plus your provider's idempotency field if it has one */ + }) + if (!res.ok) throw new Error(`Postmark error: ${res.statusText}`) + + const { MessageID } = (await res.json()) as { MessageID: string } + return { id: MessageID } // [!code ++] +}, +``` + ## Learn More diff --git a/apps/web/content/docs/dev/email/index.mdx b/apps/web/content/docs/dev/email/index.mdx index ab62c3b68..6272fda51 100644 --- a/apps/web/content/docs/dev/email/index.mdx +++ b/apps/web/content/docs/dev/email/index.mdx @@ -50,6 +50,21 @@ response quick even when the mail provider is having a moody afternoon. path. +`deliver()` takes an optional `idempotencyKey` and returns what the provider +reported, such as its message id: + +```ts +const email = await c.get('email').build(args) +const { id } = await c.get('email').deliver(email, { + idempotencyKey: `order-receipt:${order.id}`, // same key on every retry +}) +``` + +Adapters whose provider deduplicates on a key, like Resend, forward it - so a +retry after an unconfirmed send is not delivered twice. SMTP has no such +feature and ignores it. [Notification emails](/docs/dev/notifications/email-and-digests) +use this for every send. + ## Configuring Email Adapters Configure your delivery transport in `apps/api/src/vitnode.api.config.ts`: @@ -106,6 +121,11 @@ Verify email configuration in the AdminCP at **System → Integrations** (`/admi description="Build beautiful React Email templates with DefaultTemplateEmail" href="/docs/dev/email/templates" /> + @@ -109,8 +112,8 @@ export const welcomeListener = buildEventListener({ Emitted by the AdminCP user `PATCH` after it commits - profile fields (email, -name, name code) and/or role assignments. The payload carries the user's -**current** values. +name, name code) and/or role assignments - and when a member saves their own +profile fields or time zone. The payload carries the user's **current** values. + + +Emitted after a new password hash is stored - when a member completes a +password reset, or when staff set one from the AdminCP user page. The staff +route answers `403` while password sign-in is turned off, so this never fires +for a password nobody could use. Both paths then sign the account out +everywhere, so `user.sessions.revoked` follows right after. + + + +**A listener would** email the account owner that their password changed, and +use the envelope's `actor` to say whether they or an administrator did it. + + + + +Emitted by `revokeSessions` once a user's sessions are deleted and their cached +copies dropped. It covers both session kinds - the site session and the AdminCP +session - and fires from every place that signs somebody out remotely: a member +ending one of their own devices, staff ending one device or all of them, and a +password change. + + + +**A listener would** write a security audit entry, or warn the member when +staff signed them out. + + + + +Emitted by `SsoConnectionModel.savePreferences` after the profile-field sources +and the per-provider sync switches are saved - from **Settings → Connected +accounts** or from the AdminCP user page. It fires on every successful save, +even when nothing changed. + +>", + }, + sync: { + description: 'The submitted sync-on-sign-in switch per provider id.', + type: 'Record', + }, + }} +/> + +**A listener would** audit-log who changed where an account's profile comes +from. + @@ -402,6 +486,30 @@ or the bottom bar, never both. +## Notification events + +Eight names, declared in `VitNodeEvents` in +`packages/vitnode/src/api/models/events.ts`. Seven of them fire **after** an +administrator's change in [AdminCP → Notifications](/docs/dev/notifications/admincp) +has been saved, and the envelope's `actor` is that administrator. +`notifications.preferences_updated` is about one member: it fires when their +own preferences are saved, by them or by staff on their AdminCP user page. + +| Event | Payload | Fires when | +| ----------------------------------- | --------------------- | -------------------------------------------------------------------------- | +| `notifications.type.updated` | `{ typeId }` | What a type does by default (list, push, email, member can edit) changes | +| `notifications.preferences_updated` | `{ userId, typeIds }` | One member's preferences are saved - `typeIds` are the types in the save | +| `notifications.preferences_reset` | `{ members }` | **Reset all members to defaults** ran - `members` is how many were reset | +| `notifications.paused` | `{}` | Notifications were paused from the danger zone | +| `notifications.resumed` | `{ requeuedEvents }` | Notifications were resumed - `requeuedEvents` waited while paused | +| `notifications.emails_cancelled` | `{ count }` | Queued emails were cancelled - `count` is how many deliveries were skipped | +| `notifications.read_all` | `{ items, members }` | Everything was marked as read for everyone | +| `notifications.deleted_all` | `{ items }` | Every member's notifications were deleted | + +**A listener would** post an audit entry to your staff channel when someone +pauses, deletes or resets everything, or note when staff changed how a single +member is notified. + ## Page layout events One name, declared in `VitNodeEvents` in @@ -810,6 +918,11 @@ handler: async (c, payload) => { An interactive publish is emitted once, by the route that performed it, and carries no `scheduleId`. +The other way out is an idempotency key on whatever the listener writes. A +notification published with `idempotencyKey: \`post:${payload.contentId}\`` +turns a second run into a no-op. See +[Idempotency keys](/docs/dev/notifications/publishing#choose-an-idempotency-key). + ### The envelope's owner is the content type's plugin `pluginId` on the envelope answers "whose event is this", not "who was running diff --git a/apps/web/content/docs/dev/meta.json b/apps/web/content/docs/dev/meta.json index 68a352357..2203ede44 100644 --- a/apps/web/content/docs/dev/meta.json +++ b/apps/web/content/docs/dev/meta.json @@ -25,6 +25,7 @@ "widgets", "working-with-users", "events", + "notifications", "---Services---", "ai", "search", diff --git a/apps/web/content/docs/dev/notifications/admincp.mdx b/apps/web/content/docs/dev/notifications/admincp.mdx new file mode 100644 index 000000000..7ac11264f --- /dev/null +++ b/apps/web/content/docs/dev/notifications/admincp.mdx @@ -0,0 +1,170 @@ +--- +title: Notifications in the AdminCP +description: Manage VitNode notifications in the AdminCP - activity at a glance, what members get by default per type, installation settings, maintenance tools and a danger zone to pause, cancel, mark read or delete everything. +icon: LayoutDashboard +--- + +Open **AdminCP → System → Notifications** (`/admin/core/system/notifications`) +to see how notifications are doing, decide what members get by default, and +tidy up when you need to. + +{/* Image prompt: The VitNode AdminCP page at /admin/core/system/notifications, light theme, 1440x900. Title "Notifications" with a "Settings" button top right. An "Activity" row of four stat cards (Emails delivered, Failure rate, Events published, Emails skipped) each with a percentage change and a small sparkline, and 24h / 7d / 30d tabs. Below, "Notification types" grouped by plugin; one row is open, showing a "Member can edit" switch and three columns of radio cards: Notification list, Push, Email. */} + +## Permissions + +The screen uses the core `notifications` staff permission module: + +| Permission | Allows | +| -------------------------- | ---------------------------------------------------------------------------------- | +| `notifications.can_view` | Open the screen and the settings sheet, read-only | +| `notifications.can_edit` | Change settings and what each type does by default | +| `notifications.can_manage` | Tools (test email, cleanup), the danger zone and **Reset all members to defaults** | + +Grant them in **Staff → Administrators** (`/admin/core/staff/admins`). See +[Staff permissions](/docs/dev/working-with-users/staff-permissions). + +## Activity + +Four numbers for the last 24 hours, 7 days or 30 days, each compared with the +period before it and drawn as a small trend line: + +- **Emails delivered** - notification emails that went out (test emails don't count). +- **Failure rate** - failed emails out of everything that was attempted. +- **Events published** - notifications plugins and core published. +- **Emails skipped** - emails that were due but not sent, for example because the + member turned email off or the notification was already read. + +Days and hours follow the site's time zone, so "today" means the same thing to +everyone on the screen. + +Hover a trend line - or tap it on a phone - to see one day (or one hour in the +24 hour view) and its count, like "Sun, Oct 4 · 3 events published". The +failure rate shows how many emails failed out of how many were tried. With the +chart focused, the arrow keys step through the same points. + +If notifications are [paused](#danger-zone), a banner at the top says so. + +## Notification types + +Every registered type, grouped by plugin. Each row shows what members get today +at a glance - notification list, push and email - and opens to change it: + +| Choice | Options | +| --------------------- | ------------------------------------------------------------------- | +| **Member can edit** | Off locks the type: members still get it, they just can't change it | +| **Notification list** | Enabled by default · Not enabled by default · Disabled | +| **Push** | Available · Disabled | +| **Email** | Enabled by default · Not enabled by default · Disabled | + +- **Enabled by default** - members get it unless they turn it off. +- **Not enabled by default** - members get it only after turning it on. +- **Disabled** - nobody gets it on that channel and the option disappears from + members' settings. + +Changes save as you click and apply to members who never chose for themselves. +Members' own choices still win, unless the type is locked. See +[How core decides](/docs/dev/notifications/preferences#how-core-decides). + + + VitNode doesn't deliver push notifications yet. The **Push** choice is saved + with the type so it's ready when push lands. + + +Mandatory types always show in the notification list and are always locked. +Switching email back to **Enabled by default** keeps the frequency (immediate, +daily or weekly) the type had before. + +### Reset all members to defaults + +The button next to **Notification types** wipes every member's own choices - +list, push and email choices - so everyone follows +the defaults on this page again. The confirmation says how many members picked +their own settings. It can't be undone. + +A member's time zone is part of their account, so a reset keeps it. + +## Settings + +The **Settings** button opens a sheet with tools and the danger zone. Only +staff with the `can_manage` permission see it. + +There is no installation-wide email switch or hourly limit: email is decided +per type, in [Notification types](#notification-types). Retention and worker +batch sizes are deployment settings, set in your API config - see +[Queue and scale → Configuration](/docs/dev/notifications/queue-and-scale#configuration). +The sheet shows the current retention period at the bottom. + +### Tools + +- **Send a test email to me** queues a sample notification email to your own + address. It can't be sent to anyone else. It only shows when an email + adapter is configured. + +Old notifications are removed every night at 02:00 by the +`notifications-cleanup` cron. + +### Danger zone + +Each action affects every member at once and asks before it runs: + +| Action | What happens | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| **Pause all notifications** | Nothing is fanned out or emailed. New events wait in the queue. **Resume** sends everything that waited | +| **Cancel all queued emails** | Emails that haven't gone out yet, including pending digests, are skipped. Notifications stay in the list | +| **Mark everything as read for everyone** | Every unread notification becomes read and every badge drops to zero, live | +| **Delete all notifications** | Empties every member's notification list. You type `delete all notifications` to confirm | + +Deleting keeps types, preferences, settings and email history. It can't be +undone. + +## Admin API + +All routes live under `/api/@vitnode/core/admin/notifications`: + +| Method | Path | Permission | +| ------ | ---------------------------- | ------------ | +| `GET` | `/overview` | `can_view` | +| `GET` | `/stats` | `can_view` | +| `GET` | `/deliveries` | `can_view` | +| `PUT` | `/types/{type}` | `can_edit` | +| `POST` | `/test-email` | `can_manage` | +| `POST` | `/reconcile` | `can_manage` | +| `POST` | `/deliveries/{id}/retry` | `can_manage` | +| `POST` | `/pause` | `can_manage` | +| `POST` | `/resume` | `can_manage` | +| `POST` | `/emails/cancel` | `can_manage` | +| `POST` | `/read-all` | `can_manage` | +| `POST` | `/delete-all` | `can_manage` | +| `POST` | `/members/reset-preferences` | `can_manage` | + +`GET /stats` takes `range` (`24h`, `7d` or `30d`) and `timeZone` (an IANA name, +like `Europe/Warsaw`). `PUT /types/{type}` merges what you send into the stored +policy: + +```json +{ + "allowInApp": true, + "inApp": false, + "allowPush": true, + "allowEmail": true, + "email": "daily", + "memberCanEdit": false +} +``` + +The deliveries list, retry and unread count repair have no buttons on this +screen any more - the routes stay for scripts and for the debug panel. + +## Related + + + + + + diff --git a/apps/web/content/docs/dev/notifications/email-and-digests.mdx b/apps/web/content/docs/dev/notifications/email-and-digests.mdx new file mode 100644 index 000000000..648ef4773 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/email-and-digests.mdx @@ -0,0 +1,154 @@ +--- +title: Email and digests +description: How VitNode sends notification email - immediate messages and daily or weekly digests in each user's time zone, with delivery records, retries, idempotency keys and send-time checks. +icon: MailCheck +--- + +Notification email reuses your configured [email adapter](/docs/dev/email) and +the [queue](/docs/dev/advanced/queue). There is nothing to set up beyond +`email: true` on a type. Each user picks one mode per type: `none`, +`immediate`, `daily` or `weekly`. + +## Immediate email + +Fan-out creates one delivery per event and user, keyed +`immediate:{eventId}:{userId}`, and queues the `notifications-email` task. That +task sends due deliveries in batches, a few at a time, so a slow provider only +slows email - never the inbox. + +Each email is rendered in the user's language with a link to the item and a +**Manage notification preferences** link to `/settings/notifications`. + +## Daily and weekly digests + +Events for digest users wait as pending email receipts. Every 5 minutes the +`notifications-schedule` cron plans the digests whose period has ended: + +- **Periods are local.** A daily digest covers one calendar day in the user's + time zone and goes out at 08:00. A weekly one covers seven days and goes out + on Monday at 08:00. The schedule is the same for everyone - only the time + zone is personal. +- **Time zone fallback.** The time zone the member set on `/settings`, then the + time zone of their language, then UTC. +- **DST-safe.** Periods are contiguous. On the night clocks change, a "day" is + 23 or 25 hours long - no day is skipped or sent twice. +- **Claimed once.** A digest's key is the user, mode and local date of its + period, such as `daily:42:2026-10-04`. Planning it again finds the key and + does nothing. +- **Never re-included.** Each event joins exactly one digest. An event that + arrives after a period ended waits for the next one. +- **Empty digests are skipped.** No email says "nothing happened". + +A digest lists up to 25 notifications, newest first, and says how many more +are waiting. + +## What is checked at send time + +Nothing is decided from the state at fan-out time. Right before any email or +digest goes out, core checks each notification again: + +- the user's current email mode for the type; +- the type's installation policy; +- the type's `access` callback; +- whether the user already read it. + + + Digests and delayed emails include only notifications that are still unread. + If the user read or archived an item in the inbox first, it is dropped from + the email. A digest left with nothing in it is skipped. + + +If the user switched modes since the event arrived, the event is re-planned for +the new mode, or dropped for `none`. A user who takes a type by email only has +no inbox item to read, so their email always goes out. + +## Delivery records + +Every send - immediate, digest or test - is a row in +`core_notification_deliveries`: + +| Status | Means | +| --------- | ---------------------------------------------------------------------------------------- | +| `pending` | Waiting for its `availableAt` time | +| `sending` | Claimed by a worker | +| `sent` | The provider accepted it. `providerMessageId` holds the provider id | +| `failed` | Every attempt failed. Retry it from the AdminCP | +| `skipped` | Nothing to send. `skipReason` says why, e.g. `empty`, `email_disabled` or `user_missing` | + +### Retries + +A failed attempt goes back to `pending` with exponential backoff: 10 seconds, +then 20, 40 and 80. After the fifth attempt it is `failed`. The stored error is +sanitized: email addresses, tokens and keys are redacted, and it is cut to 500 +characters. + +[Retrying with `POST /deliveries/{id}/retry`](/docs/dev/notifications/admincp#admin-api) reuses +the same row, the same idempotency key and the same notifications - a retry can +never turn into a second, different email. + +## Idempotency and duplicates + +Every send passes a stable key to your adapter as `idempotencyKey`: +`vitnode-notification-` plus the delivery key. Every attempt of one delivery +uses the same key. + +That matters for one unavoidable gap: the provider accepted the email, then the +worker crashed before recording it. The delivery is still `sending`, and after +15 minutes the scheduler puts it back to `pending`. + +- **Resend** forwards the key, and drops a second send with the same key for 24 + hours. No duplicate. +- **SMTP (Nodemailer)** has no idempotency. The retry can arrive twice. + +So notification email is **at least once**. Exactly once is only as good as +your provider's deduplication. + +### Support idempotency in a custom adapter + +`sendEmail` receives an optional `idempotencyKey` and may return `{ id }`: + +```ts title="apps/api/src/lib/email/acme-mail-adapter.ts" +import type { EmailApiPlugin } from '@vitnode/core/api/models/email' + +export const AcmeMailAdapter = ({ + apiKey, + from, +}: { + apiKey: string + from: string +}): EmailApiPlugin => ({ + sendEmail: async ({ to, subject, html, text, idempotencyKey }) => { + const res = await fetch('https://api.acmemail.example/v1/send', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), + }, + body: JSON.stringify({ from, to, subject, html, text }), + }) + if (!res.ok) throw new Error(`Acme Mail error: ${res.status}`) + + const { id } = (await res.json()) as { id: string } + + return { id } + }, +}) +``` + +Forward the key in whatever field your provider documents for deduplication, +and ignore it if the provider has none. Throw on failure so the delivery is +retried, and return the provider's message id so it shows in the AdminCP. +Returning nothing is fine too. See +[Custom email adapter](/docs/dev/email/custom-adapter). + +## Related + + + + + + diff --git a/apps/web/content/docs/dev/notifications/inbox-and-realtime.mdx b/apps/web/content/docs/dev/notifications/inbox-and-realtime.mdx new file mode 100644 index 000000000..8b2ba88fb --- /dev/null +++ b/apps/web/content/docs/dev/notifications/inbox-and-realtime.mdx @@ -0,0 +1,188 @@ +--- +title: Inbox and realtime +description: How VitNode's notification inbox tracks read state, groups events, keeps the unread count exact and pushes it live over the WebSocket - plus the REST endpoints and frontend components. +icon: Inbox +--- + +Every recipient gets inbox items in `core_notifications` and one stored unread +count in `core_notification_user_state`. Each change to a user's inbox updates +that count in the same transaction and pushes the new absolute value to their +open tabs. + +## Read and unread + +An item is **unread** when it is not archived and `readSeq < activitySeq`. + +- `activitySeq` starts at 1 and goes up each time a grouped event joins the + item. +- `readSeq` records how far the user has read. + +Listing the inbox never marks anything read. Opening an item, marking it read, +or "mark all as read" does. + +### The read boundary + +The client sends the `activitySeq` it rendered as `throughSeq`: + +```http +POST /api/@vitnode/core/notifications/42/read +Content-Type: application/json + +{ "throughSeq": 3 } +``` + +If a fourth reply joined the item after the bell rendered it, `activitySeq` is +now 4. The item stays unread, because the user has not seen reply four yet. +Without `throughSeq`, the item is read up to its current activity. Repeating +the call changes nothing. + +### Mark all as read + +`POST /read-all` takes the user's state lock and marks every unread item read in +one statement. A fan-out that committed before the lock is included. One still +running waits for the lock and then adds its item as unread, so nothing that +arrives during the click is lost. Pass `{ "category": "social" }` to limit it +to one category. + +## Grouping + +Types with [`grouping`](/docs/dev/notifications/notification-types#group-related-events-with-grouping) +merge events into one item per user, type, group key and time window: + +- **Window buckets.** Windows are fixed slots counted in UTC, not sliding. + With `windowMinutes: 60`, events between 14:00 and 14:59 UTC share an item, + and an event at 15:00 starts a new one. +- **Reopening.** A new event in a read item makes it unread again. +- **Archive revival.** A new event in an archived item brings it back to the + inbox. +- **Ordering.** Items sort by last activity, so a grouped item jumps to the top + when an event joins it. +- **The bell counts items, not events.** Ten replies in one grouped item add 1 + to the unread count, not 10. + +## Unavailable items + +Before returning a page, core re-checks every item: the type is still +registered, its stored data still parses, `access` still allows the user and +`present` still renders. If any check fails, the item comes back as a +placeholder: + +```json +{ + "id": 42, + "available": false, + "title": "This notification is no longer available.", + "target": null, + "actors": [] +} +``` + +The placeholder keeps its read state, so users can still read or archive it and +the count never includes a ghost they cannot clear. This covers deleted content, +revoked access and uninstalled plugins. + +## The unread count + +The count reaches the browser from three sources. Each one carries the same +pair: + +```ts +interface NotificationState { + unread: number // absolute, never a +1 or -1 + revision: number // goes up with every committed change for this user +} +``` + +1. **Session payload.** `GET /api/@vitnode/core/users/session` includes + `user.notifications`. It is read fresh on every request, never cached with + the session user. +2. **WebSocket.** Every committed inbox change sends `notificationsStateChannel` + to that user's connections, with a `reason` such as `created`, `read`, + `read_all`, `archived`, `removed` or `reconciled`. +3. **State endpoint.** `GET /api/@vitnode/core/notifications/state` returns the + current pair. + +The client keeps whichever state has the **highest revision** and ignores +anything older. A slow session response can never undo a newer realtime update. +Messages are sent only after commit, so the browser never sees a count that was +rolled back. + +### Reconnects and background tabs + +WebSocket messages sent while a tab was offline are not replayed. Instead, +`NotificationStateSync` re-reads `/state` every time the socket connects, and +when a tab returns after more than 30 seconds in the background. + +No socket at all? That happens when the API is mounted inside the web app on +the same origin (the default `apps/web` setup opens no WebSocket), or while +the socket is down. The count is then re-read every 60 seconds while the tab is +visible - one primary-key read - so the bell is at most a minute behind. + +When the count changes, open notification lists refresh once per burst - 1 +second after the last change, and at most 5 seconds after the first - instead of +once per message. + +### Multiple API instances + + + With `REDIS_URL` set, realtime messages are relayed between API instances over + Redis pub/sub, so a fan-out on instance A reaches a tab connected to instance + B. Without Redis, realtime only reaches clients connected to the same + instance. Other users still catch up from the session payload or the `/state` + endpoint on their next reconnect or page load. + + +See [Redis](/docs/dev/advanced/redis) and [WebSocket](/docs/dev/websocket). + +## REST endpoints + +All routes act on the signed-in user and return `401` for guests. Paths are +relative to `/api/@vitnode/core/notifications`. + +| Method | Path | Does | +| ------ | --------------- | ---------------------------------------------------------------------------------------------- | +| `GET` | `/` | One page, newest activity first. Query: `limit` (1-50), `cursor`, `unread`, `category`, `type` | +| `GET` | `/state` | `{ unread, revision }` | +| `POST` | `/{id}/read` | Mark read, optional body `{ throughSeq }` | +| `POST` | `/{id}/unread` | Mark unread again | +| `POST` | `/{id}/archive` | Hide from the inbox | +| `POST` | `/read-all` | Mark all read, optional body `{ category }` | +| `GET` | `/preferences` | Types and their channels | +| `PUT` | `/preferences` | Save them | + +Mark routes return the new `{ unread, revision }`. Another user's item id +answers `404`, exactly like a missing one. + +Call them from a page with the universal fetcher: + +```ts +const response = await fetcher({ + plugin: '@vitnode/core', + method: 'get', + module: 'notifications', + path: '/state', +}) +``` + +## Frontend pieces + +Core ships the whole UI. Nothing needs wiring in a default theme. + +| Piece | Where | +| ----------------------- | ----------------------------------------------------------------------- | +| Bell with unread badge | Site header, every screen size. Loads the recent list only when opened. | +| Notifications Center | `/notifications` - all or unread, category filter, infinite scroll | +| Preferences | `/settings/notifications` - channels and per type choices | +| `NotificationStateSync` | Mounted with the realtime listeners. Keeps the count in step | + +{/* Image prompt: The VitNode Notifications Center page at /notifications on a phone, 390x844, light theme. A heading "Notifications", tabs "All" and "Unread", a category select, and a list of notification rows with avatars, bold unread titles, a muted body line, relative times and a small overflow menu with "Mark as unread" and "Archive". */} + +## Related + + + + + diff --git a/apps/web/content/docs/dev/notifications/index.mdx b/apps/web/content/docs/dev/notifications/index.mdx new file mode 100644 index 000000000..9c2314ff1 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/index.mdx @@ -0,0 +1,195 @@ +--- +title: Notifications +description: Add persistent notifications to a VitNode plugin - an inbox, a live unread count, push and email choices, daily or weekly digests, all from one publish call. +icon: Bell +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +The Notifications Center is VitNode's inbox. A plugin publishes an event with +`c.get("notifications").publish()`, and core stores it, works out who receives +it, updates every unread count in realtime and sends the emails and digests +users asked for. + +Your plugin decides who **might** care. Core decides who **actually** receives +what, and delivers it. + +{/* Image prompt: The VitNode site header on desktop with the notification bell open as a popover. The bell shows a small "3" badge. The popover lists three notifications with avatars, bold unread titles like "New article: Shipping VitNode 2.0", relative times, a "Mark all as read" button at the top and a "View all" link at the bottom. Clean neutral UI, light theme, 1440x900. */} + +## Quick start + + + + + +### Declare a notification type + +```ts title="plugins/forum/src/api/lib/notifications.ts" +import { buildNotificationType } from '@vitnode/core/api/lib/notifications/registry' +import { z } from 'zod' + +export const topicReplyNotification = buildNotificationType({ + id: 'forum.topic_reply', + version: 1, + schema: z.object({ topicId: z.number(), topicTitle: z.string() }), + category: 'social', + label: '@acme/forum.notifications.topic_reply.label', + defaults: { inApp: true, email: 'daily' }, + email: true, + present: ({ data, t }) => ({ + title: t('@acme/forum.notifications.topic_reply.title', { + title: data.topicTitle, + }), + target: `/forum/topics/${data.topicId}`, + }), +}) +``` + +Every field is described in [Notification types](/docs/dev/notifications/notification-types). + + + + + +### Register it on your API plugin + +```ts title="plugins/forum/src/config.api.ts" +import { buildApiPlugin } from '@vitnode/core/api/lib/plugin' + +import { topicReplyNotification } from '@/api/lib/notifications' +import { CONFIG_PLUGIN } from '@/const' +import apiMessages from '@/locales/api' + +export const forumApiPlugin = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + messages: apiMessages, // [!code ++] + notificationTypes: [topicReplyNotification], // [!code ++] + modules: [topicsModule], + }) +``` + +The `label` and `title` keys live in the plugin's **API** messages +(`src/locales/api/en.json`), because the server renders notifications. See +[Server-side translations](/docs/dev/i18n/server). + + + + + +### Publish when something happens + +```ts title="plugins/forum/src/api/modules/topics/routes/reply.route.ts" +await c.get('db').transaction(async (tx) => { + const [reply] = await tx.insert(forum_replies).values(values).returning() + + await c.get('notifications').publish({ + type: topicReplyNotification, + tx, + recipients: [topic.authorId], + subject: { type: 'forum.topic', id: topic.id }, + data: { topicId: topic.id, topicTitle: topic.title }, + idempotencyKey: `reply:${reply.id}`, + }) +}) +``` + +The topic author sees it in the bell a moment after the response is sent. See +[Publish notifications](/docs/dev/notifications/publishing). + + + + + +### Apply the database migration + +The Notifications Center adds six `core_notification*` tables. Build and +migrate once after upgrading: + + + +```bash tab="bun" +bun run build:plugins && bun run db:migrate +``` + +```bash tab="pnpm" +pnpm build:plugins && pnpm db:migrate +``` + +```bash tab="npm" +npm run build:plugins && npm run db:migrate +``` + + + + + + + +## Who does what + +| Your plugin | Core | +| ---------------------------------------------------------- | ------------------------------------------------------------- | +| Declares notification types | Builds the preferences page from them | +| Picks candidates: `recipients` | Removes the actor, deleted users and opted-out users | +| Answers "who may see this?" in `access` | Calls it in batches, again before email, and when listing | +| Renders plain text in `present` | Strips markup, drops unsafe links, localizes per recipient | +| Publishes once per real-world happening (`idempotencyKey`) | Deduplicates, retries and never delivers twice in-app | +| | Unread counts, realtime, grouping, email, digests and cleanup | + +## How a notification travels + +```text +publish() one row in core_notification_events + │ + a "notifications-fanout" queue task + │ (same transaction when you pass tx) + ▼ +fan-out, in batches of 500 candidates → opted out? access? + │ + ├─► receipt per event and user the "already delivered" guard + ├─► inbox item (or grouped) core_notifications + ├─► unread count + revision core_notification_user_state + │ └─► WebSocket push notificationsStateChannel + └─► email delivery immediate now, digests on schedule + └─► "notifications-email" queue task → your email adapter +``` + +1. **Stored once.** An event is one row however many people receive it. +2. **Durable fan-out.** A queue task delivers it in batches. Each batch commits + with its cursor, so a crash resumes where it stopped. +3. **Per-recipient inbox.** Every recipient gets an inbox item. Types with + `grouping` merge related events into one item. +4. **Counter and realtime.** Each user has one stored unread count with a + revision. Every change pushes the new absolute count over the WebSocket. +5. **Email and digests.** Email runs in its own queue task, so a slow mail + provider only delays email - never the inbox. + +## Learn more + + + + + + + + diff --git a/apps/web/content/docs/dev/notifications/meta.json b/apps/web/content/docs/dev/notifications/meta.json new file mode 100644 index 000000000..f2aa1d688 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/meta.json @@ -0,0 +1,18 @@ +{ + "title": "Notifications", + "description": "Persistent inbox, unread counts, realtime updates, email and digests for every plugin", + "icon": "Bell", + "pages": [ + "index", + "notification-types", + "publishing", + "inbox-and-realtime", + "preferences", + "email-and-digests", + "---Operate---", + "queue-and-scale", + "admincp", + "---Guides---", + "migration-and-troubleshooting" + ] +} diff --git a/apps/web/content/docs/dev/notifications/migration-and-troubleshooting.mdx b/apps/web/content/docs/dev/notifications/migration-and-troubleshooting.mdx new file mode 100644 index 000000000..608509090 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/migration-and-troubleshooting.mdx @@ -0,0 +1,218 @@ +--- +title: Migration and troubleshooting +description: Upgrade a VitNode plugin from toast-only realtime notifications to the Notifications Center, apply the database migration, and fix a bell that does not update, late notifications, missing emails or wrong counts. +icon: Wrench +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Before the Notifications Center, a "notification" was a toast pushed over the +WebSocket. It vanished on reload and never reached anyone offline. Toasts still +work - persistent notifications now have a proper home. + +## Apply the database migration + +The Notifications Center adds six tables to core: + +| Table | Holds | +| ------------------------------ | ---------------------------------------------------- | +| `core_notification_events` | One row per published event, with its fan-out cursor | +| `core_notifications` | Inbox items, one per recipient (or per group) | +| `core_notification_receipts` | One row per event and recipient, and its email state | +| `core_notification_user_state` | Unread count, revision and preferences | +| `core_notification_deliveries` | Email sends and their attempts | +| `core_notification_settings` | Installation settings and per-type policy | + +Build the plugins and migrate: + + + +```bash tab="bun" +bun run build:plugins && bun run db:migrate +``` + +```bash tab="pnpm" +pnpm build:plugins && pnpm db:migrate +``` + +```bash tab="npm" +npm run build:plugins && npm run db:migrate +``` + + + +## Toasts still work + +`sendToUser()` with `notificationsChannel` is unchanged. Use it for transient +feedback that does not need to be kept, like "Your import started": + +```ts +import { notificationsChannel } from '@vitnode/core/ws/notifications' + +c.get('realtime').sendToUser(userId, notificationsChannel, { + title: 'Import started', + description: "We'll let you know when it's done.", + type: 'info', +}) +``` + +A toast reaches open tabs only. No inbox item, no unread count, no email, no +preferences. + +### The dashboard widget now keeps a copy + +The AdminCP dashboard's **send notification** widget still shows the toast, and +now also stores the message in the user's inbox as a `core.admin_message` +notification. A user who was offline or closed the toast still sees it. +Users can turn these off on `/settings/notifications` like any other type. + +## Move a plugin from toasts to `publish()` + + + + + +### Find toasts users should keep + +Anything a user would look for later - a reply, a mention, an approval, a +finished export - belongs in the inbox. Keep toasts for "something is happening +right now". + + + + + +### Declare a type for each + +```ts +export const exportReadyNotification = buildNotificationType({ + id: 'shop.export_ready', + version: 1, + schema: z.object({ exportId: z.number() }), + category: 'system', + label: '@acme/shop.notifications.export_ready.label', + defaults: { inApp: true, email: 'none' }, + present: ({ data, t }) => ({ + title: t('@acme/shop.notifications.export_ready.title'), + target: `/shop/exports/${data.exportId}`, + }), +}) +``` + +Register it with `notificationTypes` on your API plugin, and put the strings in +`src/locales/api/en.json`. See +[Notification types](/docs/dev/notifications/notification-types). + + + + + +### Replace the toast with `publish()` + +```ts +// [!code --:4] +c.get('realtime').sendToUser(user.id, notificationsChannel, { + title: 'Your export is ready', + type: 'success', +}) +// [!code ++:7] +await c.get('notifications').publish({ + type: exportReadyNotification, + recipients: [user.id], + allowSelf: true, + data: { exportId: job.id }, + idempotencyKey: `export:${job.id}`, +}) +``` + +The bell updates live through the same WebSocket, so users still see it +immediately. `allowSelf` matters here: the user who started the export is also +the request's actor. + + + + + +## Troubleshooting + +### The bell does not update live + +- **Check the WebSocket.** The browser needs a working `/api/ws` connection. + See [WebSocket](/docs/dev/websocket). +- **Several API instances?** Set `REDIS_URL`. Without Redis, realtime only + reaches clients on the instance that did the fan-out. Others see the new + count on their next reconnect, after 30 seconds in a background tab, or on + page load. + +### Notifications arrive a minute late + +Delivery starts right after the publishing request's response. Anything that +misses that is delivered by the queue worker, which runs from cron every +minute. If notifications never arrive, or arrive much later: + +- your [cron adapter](/docs/dev/cron) isn't calling the queue worker - in + development there is no `CRON_SECRET`, so queued tasks wait until you run + them; +- notifications are [paused](/docs/dev/notifications/admincp#danger-zone) - + the AdminCP shows a banner; +- **AdminCP → Advanced → Queue** shows failed `notifications-fanout` tasks - + the error says why. + +### Emails are not sending + +Check, in this order: + +1. An [email adapter](/docs/dev/email) is configured - **Send a test email** + in the AdminCP [Settings tools](/docs/dev/notifications/admincp#tools) + proves it. +2. The type's email isn't _Disabled_ in + [Notification types](/docs/dev/notifications/admincp#notification-types). +3. The user picked an email mode for the type - the default may be `none`. +4. `GET /deliveries` in the [Admin API](/docs/dev/notifications/admincp#admin-api): + `failed` shows a sanitized error, and `skipped` shows a reason such as + `empty` (everything was already read). + +Remember digests only go out after the period ends in the user's time zone, and +only include notifications that are still unread. + +### The unread count looks wrong + +`POST /reconcile` in the [Admin API](/docs/dev/notifications/admincp#admin-api) +recalculates every count from the inbox and pushes the corrected badge. Please +report how you got there - the count is updated in the same transaction as +every inbox change, so drift means a bug. + +### Items say "This notification is no longer available" + +That is a placeholder. The content was deleted, the user lost access, the +plugin was uninstalled, or its stored data no longer matches the type's schema. +Users can still read or archive it. Plugins can tidy these up with +[`remove()`](/docs/dev/notifications/publishing#remove-notifications). + +### The digest came at the wrong time + +Digests follow the time zone the member set under **Region** on `/settings`. +Without one, the time zone of their language is used, then UTC. Daily digests +go out at 08:00 and weekly ones on Monday at 08:00. The scheduler runs every 5 +minutes, +so a digest can arrive up to 5 minutes after the hour. + +## Testing + +Integration tests for notifications need real PostgreSQL: row locks, +`ON CONFLICT` and concurrent transactions. They run when +`VITNODE_TEST_POSTGRES_URL` is set, and are skipped otherwise: + +```bash +VITNODE_TEST_POSTGRES_URL=postgresql://root:root@localhost:5432/postgres pnpm --filter @vitnode/core test +``` + +Each test file creates its own throwaway database and drops it afterwards, so +files run in parallel without seeing each other's rows. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/notifications/notification-types.mdx b/apps/web/content/docs/dev/notifications/notification-types.mdx new file mode 100644 index 000000000..98b26b969 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/notification-types.mdx @@ -0,0 +1,311 @@ +--- +title: Notification types +description: Reference for buildNotificationType - ids, schema versions, presentation, access checks, grouping, email and mandatory types. +icon: Shapes +--- + +import { TypeTable } from 'fumadocs-ui/components/type-table' + +A notification type describes one kind of notification: what data it stores, +how it reads in the inbox, who may see it and how users receive it by default. + +## Declare a type + +```ts title="plugins/forum/src/api/lib/notifications.ts" +import { buildNotificationType } from '@vitnode/core/api/lib/notifications/registry' +import { z } from 'zod' + +export const topicReplyNotification = buildNotificationType({ + id: 'forum.topic_reply', + version: 1, + schema: z.object({ + topicId: z.number().int().positive(), + topicTitle: z.string().max(255), + }), + category: 'social', + label: '@acme/forum.notifications.topic_reply.label', + description: '@acme/forum.notifications.topic_reply.description', + subjectType: 'forum.topic', + defaults: { inApp: true, email: 'daily' }, + email: true, + grouping: { windowMinutes: 60 }, + access: async ({ c, data, userIds }) => + await canReadTopic(c, data.topicId, userIds), + present: ({ actors, actorCount, data, t }) => ({ + title: t('@acme/forum.notifications.topic_reply.title', { + name: actors[0]?.name ?? '', + others: actorCount - 1, + title: data.topicTitle, + }), + target: `/forum/topics/${data.topicId}`, + }), +}) +``` + +The returned object is what you register **and** what you pass to `publish()`, +so `data` is type-checked against `schema` at every call site. + +## Register types + +Pass them to `buildApiPlugin()`: + +```ts title="plugins/forum/src/config.api.ts" +export const forumApiPlugin = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + messages: apiMessages, + notificationTypes: [topicReplyNotification], + modules: [topicsModule], + }) +``` + +Or to any module, nested ones included, with `buildModule()`: + +```ts title="plugins/forum/src/api/modules/topics/topics.module.ts" +export const topicsModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: 'topics', + routes: [replyRoute], + notificationTypes: [topicReplyNotification], // [!code ++] +}) +``` + +Core collects the types from every installed plugin when the API starts. The +same type id registered twice - by one plugin or by two - stops startup with +an error naming both owners. + +## `buildNotificationType` options + +', + required: true, + }, + category: { + description: + 'Groups types on the preferences page and in inbox filters. 1-50 characters of a-z, 0-9, "_" or "-". Core translates account, content, social and system.', + type: 'string', + required: true, + }, + label: { + description: 'API message key of the name shown on the preferences page.', + type: 'string', + required: true, + }, + description: { + description: 'API message key of a one-line explanation under the label.', + type: 'string', + }, + defaults: { + description: + 'What a user gets before they change anything. Admins can override it per installation.', + type: '{ inApp: boolean; email: "none" | "immediate" | "daily" | "weekly" }', + required: true, + }, + present: { + description: + "Renders one inbox item as plain text, in the recipient's language.", + type: '(args: NotificationPresentArgs) => { title: string; body?: string; target?: string | null }', + required: true, + }, + email: { + description: + 'Turns on the email channel. true reuses present; a function words the email separately. Required when defaults.email is not none.', + type: 'boolean | (args) => { subject: string; title: string; body?: string; actionLabel?: string }', + }, + access: { + description: + 'Returns which of userIds may see the notification. Called in bounded batches - answer with one query.', + type: '(args: { c, data, subject, userIds }) => number[] | Promise', + }, + grouping: { + description: + 'Merges related events that land in the same time window into one inbox item.', + type: '{ windowMinutes: number; key?: (args: { data, subject }) => string | null }', + }, + subjectType: { + description: + 'The kind of subject this type is about, such as forum.topic. publish() rejects any other subject type.', + type: 'string', + }, + mandatory: { + description: + 'Account and security messages: always in-app, cannot be changed by the user.', + type: 'boolean', + default: 'false', + }, + migrate: { + description: + 'Upgrades data stored by an older version before it is parsed.', + type: '(data: unknown, fromVersion: number) => unknown', + }, + }} +/> + +`buildNotificationType()` throws at import time when an option is invalid: a +bad id or category, a version below 1, a grouping window outside 1-10,080 +minutes (one week), or an email default without `email`. + +## Render the inbox item with `present` + +`present` runs on the server every time an item is listed or emailed, in the +recipient's language. It receives: + + + +Put the strings in your plugin's API messages, next to the type: + +```json title="plugins/forum/src/locales/api/en.json" +{ + "@acme/forum": { + "notifications": { + "topic_reply": { + "label": "Replies to your topics", + "description": "When someone replies to a topic you started.", + "title": "{others, plural, =0 {{name} replied} one {{name} and # other replied} other {{name} and # others replied}} to {title}" + } + } + } +} +``` + +A missing `label` key shows the type id instead, so a forgotten translation is +visible, not broken. Category names are looked up as +`core.notifications.categories.` and then +`.notifications.categories.`. + +### Output is plain text with safe links + +Core cleans whatever `present` returns before anyone sees it: + +- **No HTML.** `title` and `body` are plain text. Markup is never rendered, + whitespace is collapsed and each string is capped at 500 characters. +- **Same-site targets only.** `target` must be a path such as `/forum/topics/42`. + Absolute URLs, `//host`, `javascript:` and backslash tricks are dropped, and + the item renders without a link. +- **Failures are contained.** If `present` throws or returns an empty title, + that item becomes an "unavailable" placeholder and the error is logged. + +## Check access with `access` + +`access` answers "which of these users may see this?". Core calls it: + +- during fan-out, once per batch of recipients (up to `fanoutBatchSize`); +- before an email or digest goes out, for that one user; +- whenever a user lists their inbox, for each item. + +Write it to answer for many users with one query. See +[Batch access checks](/docs/dev/notifications/publishing#check-access-in-batches) +for an example. Without `access`, every candidate may see the notification. + +## Group related events with `grouping` + +```ts +grouping: { + windowMinutes: 60, + key: ({ data }) => `topic:${data.topicId}`, +}, +``` + +Events with the same type and key, for the same user, in the same +`windowMinutes` window, join one inbox item: "Alex and 4 others replied to +Shipping day". The key defaults to the event's subject. An event without a +subject, or a `key` that returns `null`, is never grouped. See +[Grouping](/docs/dev/notifications/inbox-and-realtime#grouping) for how grouped +items behave. + +## Change the data shape with `version` and `migrate` + +Events are stored for weeks. When `schema` changes, bump `version` and teach +`migrate` to upgrade the old shape. Here version 1 stored `title`, and version 2 +renamed it to `topicTitle`: + +```ts +export const topicReplyNotification = buildNotificationType({ + id: 'forum.topic_reply', + version: 2, + schema: z.object({ topicId: z.number(), topicTitle: z.string() }), + migrate: (data, fromVersion) => { + if (fromVersion !== 1) return data + const old = data as { title: string; topicId: number } + + return { topicId: old.topicId, topicTitle: old.title } + }, + // ... +}) +``` + +Data that cannot be upgraded - no `migrate`, a newer stored version, a thrown +error or a failed parse - is never shown. Undelivered events fail, and inbox +items render as placeholders. + +## Mandatory types + +```ts +mandatory: true, +``` + +Use `mandatory` for account and security notices users must not miss in the +inbox: always delivered in-app and fixed on the preferences page. +Its email follows the installation default. Password resets and sign-in codes +are **not** notifications - keep sending them with `c.get("email").send()`. See +[Mandatory types and account email](/docs/dev/notifications/preferences#mandatory-types-and-account-email). + +## Related + + + + + diff --git a/apps/web/content/docs/dev/notifications/preferences.mdx b/apps/web/content/docs/dev/notifications/preferences.mdx new file mode 100644 index 000000000..f8e11523f --- /dev/null +++ b/apps/web/content/docs/dev/notifications/preferences.mdx @@ -0,0 +1,151 @@ +--- +title: Preferences +description: How VitNode decides which notifications each user receives in the notification list, as push and by email - installation policy, mandatory types, user choices and time zone. +icon: SlidersHorizontal +--- + +Users choose, per notification type, whether it shows in their notification +list, whether it comes as push, and how it reaches them by email. Core builds the preferences page at `/settings/notifications` +from the registered types, so a new plugin's types appear there with no +migration and no UI code. + +{/* Image prompt: The VitNode settings page at /settings/notifications on desktop, light theme, 1440x900. At the top a "Where notifications reach you" card with two rows: Push with a small "Turn on" button, and Email showing the member's address with an on switch. Below, "What you get notified about": one card with small category header rows and accordion rows; each row shows a type label, a one-line description and bell, phone and mail icons that light up blue when on. One row is open, showing Notification list, Push and Email switches, the email switch with a "Daily digest" select and the line "Goes into your daily digest." */} + +## The settings page + +`/settings/notifications` has two parts: + +- **Where notifications reach you** - push and email for the whole account. + Push asks the browser for permission on this device. The **Email** switch + turns notification email off for every type the member can change, after a + confirmation, and on again - back to exactly what they had, or to the + installation defaults after a reload. +- **What you get notified about** - one accordion row per type, grouped by + category. The bell, phone and mail icons show at a glance what is on. Open a + row to flip each channel and pick the email frequency: right away, the daily + digest or the weekly digest. + +Every change saves instantly and says so with a toast. + + + Push choices are stored and respected by the policy today, but VitNode does + not deliver push yet. Members can set them up now, so nothing changes for them + when delivery lands. + + +## How core decides + +One function decides what a user receives, in this order: + +1. **Installation policy disabled the type** → nothing. Mandatory types ignore + this. +2. **Mandatory type** → always in-app. Email follows the installation default. +3. **The user's own choice** for the type - skipped when the type is locked + for members (**Member can edit** off in the + [AdminCP](/docs/dev/notifications/admincp#notification-types)). +4. **The installation default** for the type, set in the + [AdminCP](/docs/dev/notifications/admincp). +5. **The type's own `defaults`.** + +When the AdminCP sets the **notification list** to _Disabled_ for a type, it +never lands in the list, whatever the user picked - email can still go out. +Locked types show a "Set by the admin" badge in the member's settings, and the +API refuses changes to them with `400`. + +The notification list, push and email are resolved separately. A user can +take a type by email only, in the list only, any mix or nothing at all. Push is +on by default wherever the installation allows it. + +### When email is available + +A type can email only when all three allow it: + +- the type declares `email`; +- an [email adapter](/docs/dev/email) is configured; +- the type's policy in the [AdminCP](/docs/dev/notifications/admincp#notification-types) + does not set email to _Disabled_. + +There is no installation-wide email switch: to stop a kind of email, disable +email on that type. + +When any of them says no, the user sees no email choice for that type and gets +`none`. + +### Example + +`forum.topic_reply` defaults to `{ inApp: true, email: "daily" }`. An +administrator changed its email default to `weekly`. + +| User | In-app | Email | +| --------------------------------- | ------ | ----------------------------- | +| Never opened preferences | yes | weekly - installation default | +| Chose `immediate` | yes | immediate - their choice | +| Turned in-app off | no | weekly - email is independent | +| Any user, after email is Disabled | yes | none | + +## What users can change + +`PUT /api/@vitnode/core/notifications/preferences` accepts a partial update: + +```json +{ + "types": { + "forum.topic_reply": { "inApp": true, "push": false, "email": "weekly" } + } +} +``` + +The route answers `400` for an unknown type, a change to a mandatory or locked +type, a channel the installation switched off for that type (list, push or +email), or an email mode the type cannot send. Choices are +stored per user and merged, so saving one type never resets another. + +## Digests + +Daily digests go out at 08:00 and weekly digests on Monday at 08:00, in the +member's [time zone](#time-zone). Nobody picks the hour or the day - one +predictable schedule keeps digests simple. See +[Email and digests](/docs/dev/notifications/email-and-digests#daily-and-weekly-digests). + +## Time zone + +The time zone belongs to the account, not to notifications: it is the +`timeZone` column on `core_users`, and members change it under **Region** on +`/settings`. It takes any IANA name, like `Europe/Warsaw`, or `null` to follow +the time zone of their language, then UTC. **Use this device's time zone** +fills it in from the browser. + +```http +PUT /api/@vitnode/core/users/me/time-zone +Content-Type: application/json + +{ "timeZone": "Europe/Warsaw" } +``` + +An unknown name answers `400`. A save emits +[`user.updated`](/docs/dev/events/built-in-events) and refreshes the session, +so `c.get("user")?.timeZone` sees the new value on the next request. + +## Mandatory types and account email + +A type with `mandatory: true` is for notices users must not miss in their inbox, +like "your account role changed". It is always delivered in-app and shows +as fixed on the preferences page. Its email follows the +installation default, as long as email is available. + + + Password resets, email verification and sign-in codes stay on + `c.get("email").send()`. They never pass through notification preferences, + type policies or digests, so a user who turned every notification off still + receives them. + + +## Related + + + + + diff --git a/apps/web/content/docs/dev/notifications/publishing.mdx b/apps/web/content/docs/dev/notifications/publishing.mdx new file mode 100644 index 000000000..bbbdfb6d0 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/publishing.mdx @@ -0,0 +1,244 @@ +--- +title: Publish notifications +description: Publish a notification from a VitNode plugin with c.get("notifications").publish() - inside your transaction, with an idempotency key, to the users your plugin names. +icon: Send +--- + +import { TypeTable } from 'fumadocs-ui/components/type-table' + +Call `c.get("notifications").publish()` from a Hono route, event listener or +queue task. Pass your transaction as `tx`, and the notification commits or +rolls back with your own write. + +## Publish inside your transaction + +```ts title="plugins/forum/src/api/modules/topics/routes/reply.route.ts" +await c.get('db').transaction(async (tx) => { + const [reply] = await tx + .insert(forum_replies) + .values({ topicId: topic.id, content, authorId: user.id }) + .returning() + + await c.get('notifications').publish({ + type: topicReplyNotification, + tx, + recipients: [topic.authorId, ...topic.participantIds], + subject: { type: 'forum.topic', id: topic.id }, + data: { topicId: topic.id, topicTitle: topic.title }, + idempotencyKey: `reply:${reply.id}`, + }) +}) +``` + +`publish()` writes one event row and one queue task in `tx`. A rolled-back +reply notifies nobody. A committed one always notifies, even if the process +dies right after the commit. + +`publish()` returns `{ eventId, duplicate }`. It does not wait for delivery. + +## Options + + | string', + required: true, + }, + data: { + description: "Validated against the type's schema.", + type: 'TData', + required: true, + }, + idempotencyKey: { + description: + 'Stable for one real-world happening, 1-255 characters. Unique per plugin and type.', + type: 'string', + required: true, + }, + recipients: { + description: + 'Candidate user ids chosen by your business rules. At most 100,000.', + type: 'readonly number[]', + }, + subject: { + description: + "What the notification is about. Drives grouping and remove(). Must match the type's subjectType.", + type: '{ type: string; id: number | string }', + }, + tx: { + description: + 'Your transaction. The event and its delivery task commit with your write.', + type: 'Transaction', + }, + actorId: { + description: + 'Who caused it. Defaults to the signed-in admin or user of the request. Pass null for system notifications.', + type: 'number | null', + }, + allowSelf: { + description: 'Deliver to the actor too.', + type: 'boolean', + default: 'false', + }, + }} +/> + +## Choose an idempotency key + +The key names **the happening**, not the attempt. Publishing the same type and +key twice is a no-op that returns the first event with `duplicate: true`, so +retries, double clicks and replayed listeners never notify twice. + +| Happening | Good key | Bad key | +| ------------------------------ | --------------------------- | ------------------------ | +| A reply was posted | `reply:${reply.id}` | `reply:${Date.now()}` | +| An article was first published | `post-published:${post.id}` | `post-${post.updatedAt}` | +| An export finished | `export:${job.id}` | `crypto.randomUUID()` | + +A random key is only right when every call really is a new message, like an +administrator sending the same text twice on purpose. + + + If two transactions publish the same key at the same moment and the first has + not committed yet, the second throws a `NotificationPublishError`. Retrying it + after the first commits returns `duplicate: true`. + + +## Pick the audience + +`recipients` holds the user ids your plugin picked: the topic author, the +people who replied, the mentioned users, the members of a group. Up to 100,000 +per event - core walks them in batches, so a big list costs your request +nothing. + +A user listed twice gets the notification once. Candidates are only +candidates. Core still drops: + +- the actor, unless `allowSelf: true`; +- user ids that do not exist; +- users who switched the type off, or an installation policy that disabled it + (see [Preferences](/docs/dev/notifications/preferences)); +- users your type's `access` callback leaves out. + +A publish with no `recipients` is stored as completed and delivers nothing. + +### Actor and `allowSelf` + +`actorId` defaults to the signed-in admin, then the signed-in user. Nobody needs +to hear about their own reply, so the actor is skipped. Set `allowSelf: true` +for messages about the actor's own work: + +```ts +await c.get('notifications').publish({ + type: exportReadyNotification, + recipients: [user.id], + allowSelf: true, + data: { exportId: job.id }, + idempotencyKey: `export:${job.id}`, +}) +``` + +In a queue task or a scheduled job there is no signed-in user, so pass the real +actor or `null` yourself. + +## Validation errors + +`publish()` throws a `NotificationPublishError` before writing anything when: + +- the type is not registered, or `type` is a different object than the + registered one; +- `data` fails the schema - the message lists each failing path; +- `idempotencyKey` is empty or longer than 255 characters; +- `subject` has another type than the type's `subjectType`, or an invalid + type or id; +- there are more than 100,000 `recipients`. + +Inside a transaction the error rolls back your write too. Catch it if the +notification is optional for that write. + +## When delivery happens + +A request that published starts delivery as soon as its response is sent - it +never delays the response. Anything not started that way - a task another +worker already claimed, or a publish outside a request - is picked up by the +queue worker, which runs every minute. A rolled-back `tx` leaves nothing to +deliver. See +[Queue and scale](/docs/dev/notifications/queue-and-scale). + +## Check access in batches + +`access` receives a bounded batch of candidate ids, never the whole audience. +Answer for the whole batch with one query: + +```ts title="plugins/forum/src/api/lib/notifications.ts" +import { and, eq, inArray } from "drizzle-orm"; + +access: async ({ c, data, userIds }) => { + const members = await c + .get("db") + .select({ userId: forum_topic_members.userId }) + .from(forum_topic_members) + .where( + and( + eq(forum_topic_members.topicId, data.topicId), + inArray(forum_topic_members.userId, userIds), + ), + ); + + return members.map(member => member.userId); +}, +``` + +When everyone shares one answer - "is the article still published?" - check +once and return `userIds` or `[]`. + +Core calls `access` again before every email and when a user lists their inbox, +so revoked access hides the item and stops the email even after delivery. A +throw during fan-out fails that batch, and the queue retries it. + +## Remove notifications + +When content is deleted or a group of users loses access, take the items out +of their inboxes. Unread counts update in the same transaction: + +```ts +await c.get('notifications').remove({ + subject: { type: 'forum.topic', id: topic.id }, +}) + +await c.get('notifications').remove({ + subject: { type: 'forum.topic', id: topic.id }, + type: 'forum.topic_reply', + userIds: removedMemberIds, +}) +``` + +`remove()` returns how many items it deleted. Without `type` and `userIds`, it +removes every item about the subject for everyone. + +You do not have to call `remove()` for correctness: an item whose `access` now +says no already renders as a placeholder. Call it to keep inboxes tidy. + +## Read a user's unread count + +```ts +const { unread, revision } = await c.get('notifications').state(user.id) +``` + +`revision` goes up with every change, so a client can tell a fresh count from +a stale one. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/notifications/queue-and-scale.mdx b/apps/web/content/docs/dev/notifications/queue-and-scale.mdx new file mode 100644 index 000000000..7bd9d3786 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/queue-and-scale.mdx @@ -0,0 +1,171 @@ +--- +title: Queue and scale +description: How VitNode delivers notifications to large audiences - queue tasks, cron jobs, batched fan-out with saved cursors, crash recovery, lock ordering and measured numbers for 1,000 recipients. +icon: Gauge +--- + +Notifications run on VitNode's database-backed [queue](/docs/dev/advanced/queue) +and [cron](/docs/dev/cron). No extra service is needed: a working queue worker +is all it takes. + +## Queue tasks + +| Task | Attempts | Does | +| ----------------------- | -------- | ----------------------------------------------------- | +| `notifications-fanout` | 10 | Delivers one event to its recipients, batch by batch | +| `notifications-email` | 5 | Sends due immediate emails and digests | +| `notifications-cleanup` | 3 | Removes notifications older than the retention period | + +All three belong to `@vitnode/core` and show up in +**AdminCP → Advanced → Queue**. + +## Cron jobs + +| Cron | Schedule | Does | +| ------------------------ | ------------- | -------------------------------------------------------------------------------------------------- | +| `notifications-schedule` | `*/5 * * * *` | Reclaims stale email sends, re-queues stalled events, plans digests, queues the email drain if due | +| `notifications-cleanup` | `0 2 * * *` | Queues the daily retention cleanup at 02:00 | + +The queue worker itself runs every minute. Without a cron adapter nothing moves +in the background - see [Troubleshooting](/docs/dev/notifications/migration-and-troubleshooting#troubleshooting). + +## Batched fan-out + +The fan-out task works through candidates in batches of `fanoutBatchSize` +(default 500, from 10 to 5,000 - see [Configuration](#configuration)), in the +order your `recipients` list gives them. Duplicates are dropped before the first +batch. + +Each batch is **one short transaction** that: + +1. locks the event row; +2. filters the batch - deleted users, opted-out users, one `access` call; +3. locks the batch's user state rows; +4. writes receipts, inbox items, unread counts and immediate email deliveries; +5. saves the cursor on the event. + +Realtime messages go out after the batch commits. A crash loses at most the +batch in flight, and the retry resumes after the last committed one. + +**Receipts make retries safe.** There is one receipt per event and user, and +only users with a new receipt go further. A batch that runs twice finds every +receipt in place and changes nothing. + +**Time budget.** One task stops after 20 seconds and queues a fresh task for +the rest, so a 100,000-recipient announcement never blocks the queue for other +work. + +## Recovery + +| Stuck thing | Recovered by | +| --------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Queue task left `processing` by a dead worker | The core queue worker, after 15 minutes: back to `pending`, or `failed` if out of attempts | +| Event unfinished with no task to finish it | `notifications-schedule`, after 10 minutes: queues a new fan-out task | +| Email delivery left `sending` | `notifications-schedule`, after 15 minutes: back to `pending` | +| Two workers on one event | The event row lock: the second waits, then sees the saved cursor | + +## 1,000 recipients, measured + +The integration test `fanout.load.integration.test.ts` publishes one event to +1,000 members plus the actor, with 200 of them listed twice and one member who +switched the type off. + +| Measurement | Result | +| -------------------------------------------- | ------------------------------------------------- | +| Inbox items created | 999 - the opted-out member and the actor got none | +| `publish()` | 5 ms | +| Fan-out with two workers racing on the event | 457 ms | +| SQL queries, batch size 200 | 71 in total for 999 recipients | +| `access` calls | One per batch | +| Replay twice after rewinding every cursor | No duplicates, every unread count still exactly 1 | + +These numbers come from a dev container with a local PostgreSQL 16. They are +indicative, not a benchmark - what matters is that queries grow per batch, not +per recipient. + +## Lock ordering + +Every inbox write - fan-out, mark read, mark all read, remove, reconcile - +locks the affected user state rows **in user id order** before touching +anything else. Two writers that share users always take locks in the same +order, so they wait for each other instead of deadlocking, and each user's +inbox changes happen one at a time. + +## Indexes + +The migration adds the indexes the hot paths need: + +| Table | Index serves | +| ------------------------------ | -------------------------------------------------------------------------------------------------------- | +| `core_notifications` | Inbox and unread lists (partial, per user by last activity), grouping upsert, subject removal, retention | +| `core_notification_events` | Idempotency (unique plugin, type, key), status scans, retention | +| `core_notification_receipts` | Primary key on event and user, digest planning (partial on pending email), actor previews | +| `core_notification_deliveries` | Claiming due sends (partial on `pending`), status lists, idempotency key | +| `core_notification_user_state` | Primary key on user - the row every write locks | + +## Retention + +`notifications-cleanup` removes, in batches: + +- inbox items whose last activity is older than `retentionDays` (default 90, + see [Configuration](#configuration)), taking unread ones off their owner's count; +- events older than that which no inbox item points at; +- finished delivery records older than that; +- pending email for events no digest claimed within 14 days - those stop + waiting for an email, and the inbox items stay. + +Run it on demand from the [AdminCP](/docs/dev/notifications/admincp#maintenance). + +## Configuration + +How much work each run does and how long notifications are kept are deployment +decisions, so they live in your API config rather than the AdminCP. Add a +`notifications` block to `buildApiConfig` in `vitnode.api.config.ts`: + +```ts title="src/vitnode.api.config.ts" +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + // ... + notifications: { + retentionDays: 30, + fanoutBatchSize: 1000, + emailBatchSize: 100, + emailConcurrency: 8, + }, +}) +``` + +Every key is optional: + +| Key | Default | Range | What it does | +| ------------------ | ------- | ------- | ---------------------------------------------------------------- | +| `retentionDays` | `90` | 1-3650 | Notifications older than this are removed by the nightly cleanup | +| `fanoutBatchSize` | `500` | 10-5000 | Recipients handled in one fan-out transaction | +| `emailBatchSize` | `50` | 1-500 | Emails one worker run picks up | +| `emailConcurrency` | `4` | 1-20 | Emails sent at the same time during a run | + +A value outside its range is clamped to the nearest limit, and anything that +isn't a whole number falls back to the default - a typo never stops +notifications. Restart the API to apply a change. + + + The defaults suit most sites. Raise `fanoutBatchSize` if one announcement to a + huge audience takes too many runs, and `emailConcurrency` if your email + provider allows more parallel sends. Lower `emailBatchSize` if a slow provider + makes runs time out. + + +The AdminCP shows the retention period in the +[Settings sheet](/docs/dev/notifications/admincp#settings) but can't change it. + +## Related + + + + + + diff --git a/apps/web/content/docs/dev/websocket.mdx b/apps/web/content/docs/dev/websocket.mdx index 9e6afcf44..e6f1fd752 100644 --- a/apps/web/content/docs/dev/websocket.mdx +++ b/apps/web/content/docs/dev/websocket.mdx @@ -10,9 +10,9 @@ VitNode provides a unified, multiplexed WebSocket connection at `/api/ws`. Brows ## Quick start -### 1. Send User Notification +### 1. Send a Toast to a User -Push real-time notifications to any signed-in user from any Hono route or service: +Push a transient toast to any signed-in user from any Hono route or service: ```ts import { notificationsChannel } from "@vitnode/core/ws/notifications" @@ -27,6 +27,15 @@ c.get("realtime").sendToUser(userId, notificationsChannel, { The user receives an immediate `sonner` toast in all open tabs across devices. + + `notificationsChannel` is for transient toasts: they reach open tabs only and + are gone on reload. For anything users should find later - with an inbox, + unread count, email and preferences - publish a persistent notification with + `c.get("notifications").publish()`. See + [Notifications](/docs/dev/notifications). The bell's unread count travels over + this same connection on `notificationsStateChannel`. + + --- ### 2. Broadcast to Everyone @@ -46,8 +55,7 @@ c.get("realtime").broadcast(myChannel, { Create typed channels in your plugin: ```ts title="plugins/chat/src/ws/chat.channel.ts" -import { defineWebSocketChannel } from "@vitnode/core/ws" -import { z } from "zod" +import { createWebSocketChannel } from "@vitnode/core/ws/types" export interface MessagePayload { roomId: string @@ -55,35 +63,37 @@ export interface MessagePayload { senderId: number } -// [!code ++:6] -export const chatChannel = defineWebSocketChannel({ - id: "chat_room_messages", - schema: z.object({ - roomId: z.string(), - message: z.string(), - senderId: z.number(), - }), +// [!code ++:5] +export const chatChannel = createWebSocketChannel({ + pluginId: "@acme/chat", + module: "rooms", + id: "messages", }) ``` +The first type parameter is what the client sends, the second what it receives. +The channel id becomes `{pluginId}_{module}_{id}`. + --- ## Client-Side Consumption -Listen for incoming channel messages with `useWebSocketChannel`: +Listen for incoming channel messages with `useVitNodeWebSocket`: ```tsx title="plugins/chat/src/views/chat-room.tsx" -import { useWebSocketChannel } from "@vitnode/core/hooks/use-websocket-channel" +import { useVitNodeWebSocket } from "@vitnode/core/ws/use-websocket" import { chatChannel } from "../ws/chat.channel" export const ChatRoom = ({ roomId }: { roomId: string }) => { const [messages, setMessages] = React.useState([]) - // [!code ++:6] - useWebSocketChannel(chatChannel, (payload) => { - if (payload.roomId === roomId) { - setMessages((prev) => [...prev, payload.message]) - } + // [!code ++:7] + useVitNodeWebSocket(chatChannel, { + onMessage: (payload) => { + if (payload.roomId === roomId) { + setMessages((prev) => [...prev, payload.message]) + } + }, }) return ( @@ -103,10 +113,16 @@ export const ChatRoom = ({ roomId }: { roomId: string }) => { - **Single Connection**: Only one `/api/ws` socket is opened per client. All features share it via message multiplexing. - **Tab Leader Election**: When a user opens multiple tabs, a Web Lock elects one leader tab to hold the socket, distributing messages across tabs via `BroadcastChannel`. - **Automatic Reconnect**: Backoff retry automatically re-establishes dropped connections within seconds. +- **Multiple Instances**: With `REDIS_URL` set, `sendToUser` and `broadcast` are relayed to every API instance over Redis pub/sub. Without Redis they only reach clients connected to the same instance. ## Learn More + +## Write and translate articles + +The article editor keeps the text in the middle and everything readers see +around it in a **Ready to publish** panel on the right. + +{/* Image prompt: VitNode AdminCP blog article editor. Large article title, slug under it, a TipTap editor, and a right panel with a "Ready to publish" checklist, a Google-style search preview and a social card. Header with Translate, a Draft/Published switch and Save changes. Light theme, 1440x900. */} + +### Check the article before you publish + +The checklist counts what a published article usually needs: + +| Check | Passes when | +| --- | --- | +| Title fits search results | Every started language has a title of 60 characters or less | +| Excerpt in every language | Every started language has an excerpt | +| Cover image | A cover image is set | +| Cover alt text in every language | Every started language describes the cover | +| Translations complete | Every language has a title, friendly URL and content | +| Translations up to date | No translation is older than the default language | + +The title, excerpt and alt text fields show a live counter against the +recommended length: 60, 160 and 125 characters. They are recommendations, not +limits, so you can still save a longer title. + +The search result and social card previews switch between languages. A missing +field falls back to the default language, the same way the public site does. + +### Publish or unpublish + +The **Draft / Published** switch sits next to **Save changes**. Switching asks +for confirmation first, because it changes what readers see immediately. + +### Translate side by side + +Pick a language from **Translate**. Each translated field then shows the default +language on the left, read-only, and the language you translate into on the +right. Your shared fields, such as categories and authors, stay in one place. + +A translation is marked **outdated** when the default language was saved after +it. **Generate from title** builds the friendly URL from the translated title. + +### Use AI helpers + +With AI configured, the editor adds: + +- **Translate** on each field, and **Translate missing fields** for a whole + language. Content keeps its HTML structure. +- **Write with AI** on the excerpt, which writes it from the title and content. + + + The buttons appear only when the API has at least one model in `ai.models`. + The routes behind them require the `can_edit` permission on articles. See + [AI](/docs/dev/ai) to configure a model. + + +AI never saves for you. Read the suggestion, then press **Save changes**. + +### Upgrade an existing blog + +Articles have a localized **Excerpt** field. Search results use it as the +description and fall back to the article content when it is empty. Run your +migrations after you upgrade `@vitnode/blog`: + + + +```bash tab="bun" +bun run db:migrate +``` + +```bash tab="pnpm" +pnpm db:migrate +``` + +```bash tab="npm" +npm run db:migrate +``` + + + ## Deliver it from a plugin Build the public article page in a plugin too, then use the Content Engine diff --git a/apps/web/content/docs/ui/tabs.mdx b/apps/web/content/docs/ui/tabs.mdx index ec160c763..9a044e923 100644 --- a/apps/web/content/docs/ui/tabs.mdx +++ b/apps/web/content/docs/ui/tabs.mdx @@ -6,7 +6,7 @@ icon: PanelsTopLeft ## Preview - + ## Usage @@ -54,7 +54,7 @@ so focus rings aren't clipped. ## Line variant - + An underline instead of a filled pill. Same motion. @@ -64,7 +64,7 @@ An underline instead of a filled pill. Same motion. ## Vertical - + Triggers stack beside the panels. The indicator and panels move up and down. diff --git a/apps/web/src/locales/@vitnode/blog/pl.json b/apps/web/src/locales/@vitnode/blog/pl.json index 226e2d983..02ac79906 100644 --- a/apps/web/src/locales/@vitnode/blog/pl.json +++ b/apps/web/src/locales/@vitnode/blog/pl.json @@ -5,13 +5,86 @@ "content": { "label": "Treść" }, - "form": { - "cover": { - "title": "Obraz wyróżniający" + "editor": { + "heading": "Edytor artykułu", + "title": { + "placeholder": "Tytuł artykułu" }, - "publish": "Publikacja", - "settings": { - "title": "Ustawienia artykułu" + "excerpt": { + "placeholder": "Jedno lub dwa zdania, które zachęcą do kliknięcia.", + "hint": "Widoczna na liście wpisów i jako opis w wynikach wyszukiwania." + }, + "alt": { + "placeholder": "Co przedstawia obraz", + "hint": "Czytany przez czytniki ekranu zamiast obrazu." + }, + "counter": { + "title": "Wyniki wyszukiwania pokazują około {max} znaków.", + "excerpt": "Wyniki wyszukiwania pokazują około {max} znaków.", + "coverImageAlt": "Czytniki ekranu czytają około {max} znaków przed pauzą." + }, + "translate": { + "button": "Tłumacz", + "menu": "Tłumacz z języka {language} na", + "complete": "Gotowe", + "missing": "{count, plural, one {brakuje # pola} few {brakuje # pól} many {brakuje # pól} other {brakuje # pola}}", + "outdated": "Nieaktualne", + "active": "Tłumaczenie na {language}", + "exit": "Zakończ tłumaczenie", + "source": "{language} · źródło", + "target": "{language}", + "field": "Przetłumacz", + "field_again": "Przetłumacz ponownie", + "field_into": "Przetłumacz na {language}", + "slug": "Utwórz z tytułu", + "missing_fields": "Przetłumacz brakujące pola", + "outdated_banner": "Wersja {language} zmieniła się po ostatnim zapisaniu tego tłumaczenia.", + "update_all": "Zaktualizuj wszystko z AI", + "dismiss": "Ukryj", + "empty_source": "Brak tekstu w wersji {language}.", + "status": { + "done": "Przetłumaczone", + "missing": "Brak", + "unavailable": "Nie ma czego tłumaczyć" + } + }, + "ai": { + "write_excerpt": "Napisz z AI", + "translating": "Tłumaczenie…", + "writing": "Pisanie…", + "done": "Gotowe. Przeczytaj przed zapisaniem.", + "error": "Zapytanie do AI nie zakończyło się. Spróbuj ponownie za chwilę.", + "not_configured": "Ta strona nie ma skonfigurowanego modelu AI." + }, + "publish": { + "open": "Gotowość do publikacji: {done} z {total}", + "title": "Gotowe do publikacji", + "checks": { + "title": "Tytuł mieści się w wynikach wyszukiwania", + "title_detail": "{languages}: dłuższy niż {max} znaków.", + "excerpt": "Zajawka w każdym języku", + "excerpt_detail": "Brakuje w: {languages}.", + "cover": "Obraz wyróżniający", + "cover_detail": "Widoczny na liście wpisów i przy udostępnianiu artykułu.", + "alt": "Tekst alternatywny w każdym języku", + "alt_detail": "Brakuje w: {languages}.", + "translations": "Tłumaczenia kompletne", + "translations_detail": "{language}: {count, plural, one {brakuje # pola} few {brakuje # pól} many {brakuje # pól} other {brakuje # pola}}", + "outdated": "Tłumaczenia aktualne", + "outdated_detail": "{languages} zmieniano dawniej niż wersję {source}.", + "open": "Otwórz: {language}", + "write": "Napisz z AI" + }, + "previews": { + "title": "Podgląd", + "language": "Język podglądu", + "search": "Wynik wyszukiwania", + "social": "Karta społecznościowa", + "fallback": "Czytelnicy w języku {language} zobaczą tekst w wersji {source} w brakujących polach.", + "no_cover": "Dodaj obraz wyróżniający, aby zobaczyć kartę społecznościową." + }, + "details": "Szczegóły", + "cover": "Obraz wyróżniający" } } }, @@ -46,7 +119,8 @@ "publishedAt": "Opublikowano", "status": "Status", "title": "Tytuł", - "updatedAt": "Zaktualizowano" + "updatedAt": "Zaktualizowano", + "excerpt": "Zajawka" }, "label": "{count, plural, one {Artykuł} few {Artykuły} many {Artykułów} other {Artykułu}}", "title": "Artykuły" diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 5c29a8ee4..de84fa86f 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -20,6 +20,10 @@ "@vitnode/core:navigation:can_delete": "Usuwanie pozycji menu głównego", "@vitnode/core:navigation:can_edit": "Edytowanie i zmiana kolejności pozycji menu głównego", "@vitnode/core:navigation:can_view": "Wyświetlanie menu głównego", + "@vitnode/core:notifications": "Powiadomienia", + "@vitnode/core:notifications:can_edit": "Edycja ustawień powiadomień", + "@vitnode/core:notifications:can_manage": "Testowe e-maile, czyszczenie i strefa zagrożenia: wstrzymanie, anulowanie e-maili, oznaczanie jako przeczytane, usuwanie i reset preferencji", + "@vitnode/core:notifications:can_view": "Podgląd ustawień i stanu dostarczania powiadomień", "@vitnode/core:queue": "Zadania w kolejce", "@vitnode/core:queue:can_view": "Wyświetlanie zadań w kolejce", "@vitnode/core:roles": "Role", @@ -55,6 +59,30 @@ "advanced": { "cron": { "desc": "Zarządzaj i monitoruj zaplanowane zadania.", + "health": { + "facts": { + "jobs": "Zadania", + "last_run": "Ostatnie uruchomienie", + "never": "Nigdy", + "next_run": "Następne" + }, + "no_adapter": "Nie skonfigurowano adaptera cron, więc zadania uruchamiają się tylko wtedy, gdy zewnętrzny harmonogram wywoła endpoint cron.", + "ok": { + "desc": "Każde zadanie zostało uruchomione o ostatniej zaplanowanej porze.", + "title": "Wszystkie zadania zgodnie z harmonogramem" + }, + "overdue": { + "desc": "Harmonogram działa, ale część zadań nie zakończyła się na czas. Zadanie, które rzuca błąd, zachowuje ostatnie udane uruchomienie, więc poszukaj błędu w logach serwera.", + "title": "{count, plural, one {# zadanie cron jest zaległe} few {# zadania cron są zaległe} many {# zadań cron jest zaległych} other {# zadań cron jest zaległych}}" + }, + "secret_rejected": "Endpoint cron odrzuca każde uruchomienie, bo CRON_SECRET nie jest ustawiony lub ma wbudowaną wartość domyślną. Ustaw CRON_SECRET na losową wartość w środowisku API i uruchom je ponownie.", + "stale": { + "desc": "Nic nie zostało uruchomione od . Kolejka, zaplanowane powiadomienia i czyszczenie są wstrzymane do czasu wznowienia harmonogramu.", + "desc_never": "Żadne zadanie nie zostało jeszcze uruchomione. Kolejka, zaplanowane powiadomienia i czyszczenie są wstrzymane do czasu uruchomienia harmonogramu.", + "help": "Sprawdź konfigurację cron i logi serwera.", + "title": "Harmonogram zatrzymany" + } + }, "list": { "actions": { "runNow": { @@ -66,14 +94,20 @@ "never": "Nigdy", "title": "Ostatnie uruchomienie" }, - "module": "Moduł", "name": "Nazwa", "nextRun": { "never": "Nigdy", "title": "Następne uruchomienie" }, - "pluginId": "ID wtyczki", - "schedule": "Harmonogram" + "plugin": "Wtyczka", + "schedule": "Harmonogram", + "search": "Szukaj zadań…", + "status": { + "on_schedule": "Zgodnie z harmonogramem", + "overdue": "Zaległe", + "title": "Status", + "waiting": "Czeka na pierwsze uruchomienie" + } }, "title": "Zadania cron" }, @@ -668,7 +702,9 @@ "insecure": "Używany jest domyślny CRON_SECRET - ustaw bezpieczny w środowisku produkcyjnym.", "jobs": "{count, plural, =0 {Brak zaplanowanych zadań} one {# zaplanowane zadanie} few {# zaplanowane zadania} many {# zaplanowanych zadań} other {# zaplanowanych zadań}}", "not_configured": "Nie skonfigurowano adaptera cron - zadania nie będą uruchamiać się automatycznie.", - "stale": "Żadne zadanie nie zostało uruchomione od ponad 6 godzin - cron może być źle skonfigurowany lub zatrzymany. Sprawdź konfigurację cron.", + "overdue": "{count, plural, one {# zadanie jest zaległe} few {# zadania są zaległe} many {# zadań jest zaległych} other {# zadań jest zaległych}} - sprawdź logi serwera.", + "stale": "Zatrzymany - żadne zadanie nie zostało uruchomione od . Sprawdź konfigurację cron.", + "stale_never": "Zatrzymany - żadne zadanie nie zostało jeszcze uruchomione. Sprawdź konfigurację cron.", "title": "Zadania cron" }, "desc": "Status podstawowych usług obsługujących tę instancję.", @@ -715,7 +751,7 @@ "status": { "active": "Aktywna", "inactive": "Nieaktywna", - "warning": "Nieosiągalna" + "warning": "Wymaga uwagi" }, "storage": { "desc": "Przesyłanie plików, takich jak awatary i załączniki, przechowywanych za pomocą adaptera magazynu.", @@ -841,7 +877,157 @@ "secondaryRoles": "Role dodatkowe", "selectRole": "Wybierz rolę", "title": "Profil użytkownika", - "updateSuccess": "Profil został zaktualizowany." + "updateSuccess": "Profil został zaktualizowany.", + "verify": { + "action": "Oznacz e-mail jako zweryfikowany", + "success": "E-mail oznaczony jako zweryfikowany", + "successDesc": "{name} może teraz otrzymywać e-maile z witryny." + }, + "badges": { + "staff": "Zespół", + "verified": "Zweryfikowany", + "unverified": "Niezweryfikowany" + }, + "tabs": { + "profileLabel": "Profil członka", + "detailsLabel": "Szczegóły członka", + "activity": "Aktywność", + "notifications": "Powiadomienia", + "security": "Hasło i klucze dostępu", + "securityShort": "Bezpieczeństwo" + }, + "personal": { + "title": "Dane osobowe", + "edit": "Edytuj dane osobowe", + "editDesc": "Opcjonalne dane z profilu członka. Puste pola zostaną wyczyszczone.", + "save": "Zapisz zmiany", + "saved": "Dane osobowe zapisane", + "savedDesc": "Profil użytkownika {name} pokazuje nowe dane.", + "firstName": "Imię", + "lastName": "Nazwisko", + "headline": "Nagłówek", + "headlineDesc": "Wyświetlany pod nazwą w profilu. Do {max} znaków.", + "phone": "Telefon", + "birthday": "Data urodzenia", + "showRealName": "Pokazuj prawdziwe imię", + "showRealNameDesc": "Pokazuje imię i nazwisko obok nazwy wyświetlanej.", + "memberId": "ID członka", + "realName": "Imię i nazwisko", + "hidden": "ukryte", + "notSet": "Nie ustawiono" + }, + "preferences": { + "title": "Preferencje", + "edit": "Edytuj preferencje", + "editDesc": "Język, strefa czasowa i newsletter tego członka.", + "save": "Zapisz zmiany", + "saved": "Preferencje zapisane", + "savedDesc": "{name} zobaczy zmianę po odświeżeniu strony.", + "language": "Język", + "timeZone": "Strefa czasowa", + "timeZoneDesc": "Używana w e-mailach z podsumowaniem i datach. Automatyczna wynika z języka.", + "timeZoneAuto": "Automatyczna", + "newsletter": "Newsletter", + "newsletterDesc": "Otrzymuje newslettery wysyłane z AdminCP.", + "subscribed": "Zapisany", + "notSubscribed": "Niezapisany" + }, + "sso": { + "title": "Połączone konta", + "edit": "Edytuj źródło profilu i synchronizację", + "editTitle": "Źródło profilu i synchronizacja", + "editDesc": "Wybierz, skąd pochodzą awatar i imię użytkownika {name} oraz czy odświeżają się przy każdym logowaniu.", + "syncTitle": "Synchronizacja przy logowaniu", + "syncFooter": "Synchronizacja odświeża powyższe pola z tego konta przy każdym logowaniu przez nie.", + "syncNoFields": "Nie jest jeszcze źródłem żadnego pola", + "syncOn": "{provider} synchronizuje przy logowaniu", + "syncOff": "Synchronizacja {provider} wyłączona", + "disconnect": "Odłącz {provider}", + "disconnectTitle": "Odłączyć {provider}?", + "disconnectDesc": "{name} nie będzie mógł logować się przez {provider}. Hasło i inne połączenia pozostaną bez zmian.", + "disconnectSubmit": "Odłącz", + "disconnected": "Odłączono {provider}", + "disconnectedDesc": "{name} nie może już logować się przez to konto.", + "connected": "Połączono", + "sourceBadge": "{fields} stąd", + "syncsBadge": "Synchronizuje przy logowaniu", + "lastMethod": "Nie można usunąć ostatniego sposobu logowania", + "lastMethodDesc": "Najpierw ustaw hasło lub dodaj inny sposób logowania.", + "loadError": "Nie udało się wczytać połączonych kont. Odśwież, aby spróbować ponownie.", + "empty": "Brak połączonych kont. Ten członek loguje się hasłem lub kluczem dostępu." + }, + "devices": { + "title": "Urządzenia", + "deviceName": "{browser} na {os}", + "unknownDevice": "Nieznane urządzenie", + "signOut": "Wyloguj to urządzenie", + "signOutLabel": "Wyloguj {device}", + "signOutTitle": "Wylogować to urządzenie?", + "signOutDesc": "{name} zostanie wylogowany na {device}, łącznie z sesją AdminCP.", + "signOutSubmit": "Wyloguj", + "signedOut": "Urządzenie wylogowane", + "signedOutDesc": "{device} musi zalogować się ponownie.", + "signOutAll": "Wyloguj wszędzie", + "signOutAllTitle": "Wylogować {name} wszędzie?", + "signOutAllDesc": "{count, plural, one {Sesja na # urządzeniu zostanie zakończona} few {Wszystkie sesje na # urządzeniach zostaną zakończone} many {Wszystkie sesje na # urządzeniach zostaną zakończone} other {Wszystkie sesje na # urządzeniach zostaną zakończone}}, łącznie z sesjami AdminCP. Można od razu zalogować się ponownie.", + "signedOutAll": "Wylogowano wszędzie", + "signedOutAllDesc": "{name} musi zalogować się ponownie na każdym urządzeniu.", + "admincp": "AdminCP", + "showAll": "Pokaż wszystkie ({count})", + "showFewer": "Pokaż mniej", + "loadError": "Nie udało się wczytać urządzeń. Odśwież, aby spróbować ponownie.", + "empty": "Nie jest zalogowany na żadnym urządzeniu." + }, + "security": { + "title": "Hasło i klucze dostępu", + "password": "Hasło", + "passwordSet": "Hasło ustawione", + "passwordNotSet": "Brak hasła", + "passwordDisabled": "Logowanie hasłem jest wyłączone w tej witrynie.", + "passwordLoadError": "Nie udało się wczytać stanu hasła. Odśwież, aby spróbować ponownie.", + "passwordChange": "Zmień", + "passwordAdd": "Ustaw hasło", + "passwordTitle": "Ustaw nowe hasło", + "passwordDesc": "{name} zostanie wylogowany ze wszystkich urządzeń i przy następnym logowaniu musi użyć nowego hasła.", + "newPassword": "Nowe hasło", + "confirmPassword": "Potwierdź hasło", + "passwordMin": "Użyj co najmniej {min} znaków.", + "passwordMismatch": "Hasła nie są takie same.", + "passwordSubmit": "Ustaw hasło", + "passwordSaved": "Hasło zmienione", + "passwordSavedDesc": "{name} został wylogowany wszędzie.", + "passkeys": "Klucze dostępu", + "passkeysEmpty": "Brak kluczy dostępu.", + "passkeysDisabled": "Klucze dostępu są wyłączone w tej witrynie.", + "passkeysLoadError": "Nie udało się wczytać kluczy dostępu. Odśwież, aby spróbować ponownie.", + "passkeySynced": "Synchronizowany", + "passkeyDeviceOnly": "Tylko to urządzenie", + "passkeyUsed": "Użyty", + "passkeyNeverUsed": "Nigdy nieużyty", + "passkeyRename": "Zmień nazwę klucza", + "passkeyRenameLabel": "Zmień nazwę {name}", + "passkeyRenameTitle": "Zmień nazwę klucza dostępu", + "passkeyRenameDesc": "Zmienia się tylko etykieta, klucz działa dalej.", + "passkeyName": "Nazwa", + "passkeyRenameSubmit": "Zapisz nazwę", + "passkeyRenamed": "Zmieniono nazwę klucza", + "passkeyDelete": "Usuń klucz dostępu", + "passkeyDeleteLabel": "Usuń {name}", + "passkeyDeleteTitle": "Usunąć ten klucz dostępu?", + "passkeyDeleteDesc": "{name} nie będzie mógł logować się kluczem „{passkey}”.", + "passkeyDeleteSubmit": "Usuń klucz", + "passkeyDeleted": "Klucz dostępu usunięty", + "passkeyDeletedDesc": "„{passkey}” nie loguje już do tego konta.", + "lastMethod": "Nie można usunąć ostatniego sposobu logowania", + "lastMethodDesc": "Najpierw ustaw hasło lub połącz inny sposób logowania." + }, + "notifications": { + "desc": "Każdy typ korzysta z ustawień powiadomień witryny, dopóki go tu nie zmienisz. Zmiany dotyczą tylko użytkownika {name}.", + "empty": "W tej witrynie nie włączono żadnych typów powiadomień.", + "saved": "Preferencja powiadomień zapisana", + "savedDesc": "Dotyczy tylko użytkownika {name}.", + "loadError": "Nie udało się wczytać preferencji powiadomień. Odśwież, aby spróbować ponownie." + } }, "verify_email": { "label": "Zweryfikuj adres e-mail", @@ -909,7 +1095,8 @@ "devices": "Urządzenia", "overview": "Przegląd", "security": "Bezpieczeństwo", - "sso": "Połączone konta" + "sso": "Połączone konta", + "notifications": "Powiadomienia" }, "overview": { "accountTitle": "Konto", @@ -932,7 +1119,15 @@ "savedDesc": "Twój profil od razu pokazuje nowe dane.", "secondaryRoles": "Role dodatkowe", "showRealName": "Pokazuj moje prawdziwe imię", - "showRealNameDesc": "Twój profil pokazuje imię i nazwisko zamiast pseudonimu. Do czasu ich ustawienia używany jest pseudonim." + "showRealNameDesc": "Twój profil pokazuje imię i nazwisko zamiast pseudonimu. Do czasu ich ustawienia używany jest pseudonim.", + "regionTitle": "Region", + "regionDesc": "Godziny podsumowań i daty są w tej strefie czasowej.", + "timeZone": "Strefa czasowa", + "timeZoneDesc": "Szukaj po mieście, np. Warsaw lub New York.", + "timeZoneAuto": "Automatycznie - z Twojego języka", + "timeZoneUseDevice": "Użyj strefy tego urządzenia ({timeZone})", + "timeZoneSaved": "Zapisano strefę czasową", + "timeZoneSavedDesc": "Podsumowania i daty są teraz w strefie {timeZone}." }, "passkeys": { "add": "Dodaj klucz dostępu", @@ -1152,6 +1347,68 @@ "saved_from": "{field}: teraz z {provider}", "saved_manual": "{field}: teraz ręcznie" } + }, + "notifications": { + "title": "Powiadomienia", + "desc": "Wybierz, co do Ciebie trafia i gdzie - na liście powiadomień, jako push albo e-mailem.", + "saved": "Zapisano", + "saved_desc": "Ustawienia powiadomień są aktualne.", + "locked": "Ustawione przez administratora", + "channels": { + "title": "Gdzie docierają powiadomienia", + "push": { + "title": "Push", + "desc": "Wysyłane na to urządzenie, nawet gdy nie przeglądasz strony.", + "enable": "Włącz push w tej przeglądarce", + "enabled": "Włączone", + "denied": "Zablokowane przez przeglądarkę. Zezwól tej stronie na powiadomienia w jej ustawieniach, aby włączyć push.", + "unsupported": "Ta przeglądarka nie obsługuje powiadomień push.", + "soon": "Wysyłka push jest w drodze - wybory poniżej zapiszemy na czas, gdy ruszy.", + "enabled_toast": "Push włączony", + "enabled_toast_desc": "Ta przeglądarka może teraz otrzymywać Twoje powiadomienia.", + "enable_short": "Włącz" + }, + "email": { + "title": "E-mail", + "desc": "Wysyłane na .", + "stop_title": "Wyłączyć wszystkie e-maile z powiadomieniami?", + "stop_desc": "Nie dostaniesz już e-maili z powiadomieniami ani podsumowań. Alerty bezpieczeństwa nadal przyjdą e-mailem - chronią Twoje konto. Lista powiadomień się nie zmienia.", + "stop_cancel": "Zostaw e-maile", + "stop_submit": "Wyłącz wszystkie e-maile", + "stopped_toast_desc": "Koniec e-maili z powiadomieniami. Alerty bezpieczeństwa nadal do Ciebie trafią.", + "restored_toast_desc": "E-maile wróciły do poprzednich ustawień.", + "desc_off": "Wyłączone - na trafiają tylko alerty bezpieczeństwa.", + "unavailable": "Żadne z Twoich powiadomień nie jest teraz wysyłane e-mailem.", + "on_toast_desc": "E-maile z powiadomieniami znowu działają." + } + }, + "types": { + "title": "O czym dostajesz powiadomienia", + "empty": "Na razie nie ma czego ustawiać.", + "summary": { + "list": "Lista powiadomień", + "push": "Push", + "email": "E-mail", + "on": "włączone", + "off": "wyłączone", + "unavailable": "niedostępne" + }, + "mandatory": "Alert bezpieczeństwa - zawsze do Ciebie trafia.", + "locked": "Ustawione przez administratora, więc nie zmienisz tego tutaj.", + "list_desc": "Pokaż pod dzwonkiem, gdy odwiedzasz stronę.", + "push_desc": "Wyślij na urządzenia, na których włączono push.", + "email_off": "Bez e-maili dla tego typu.", + "frequency_label": "Jak często wysyłać e-mail: {type}", + "email_immediate": "Wysyłane od razu.", + "email_daily": "Trafia do dziennego podsumowania.", + "email_weekly": "Trafia do tygodniowego podsumowania." + }, + "email_modes": { + "none": "Wyłączone", + "immediate": "Od razu", + "daily": "Podsumowanie dzienne", + "weekly": "Podsumowanie tygodniowe" + } } }, "sign_in": { @@ -1722,6 +1979,28 @@ "my_profile": "Mój profil", "settings": "Ustawienia" }, + "notifications": { + "title": "Powiadomienia", + "bell_label": "Powiadomienia", + "bell_label_unread": "Powiadomienia, {count, plural, one {# nieprzeczytane} few {# nieprzeczytane} many {# nieprzeczytanych} other {# nieprzeczytanych}}", + "recent": "Ostatnie powiadomienia", + "mark_all_read": "Oznacz wszystkie jako przeczytane", + "all_read": "Wszystko przeczytane", + "all_read_desc": "Wszystkie powiadomienia są oznaczone jako przeczytane.", + "mark_read": "Oznacz jako przeczytane", + "mark_unread": "Oznacz jako nieprzeczytane", + "archive": "Archiwizuj", + "archived": "Powiadomienie zarchiwizowane", + "unread": "Nieprzeczytane", + "loading": "Wczytywanie powiadomień…", + "load_error": "Nie udało się wczytać powiadomień.", + "retry": "Spróbuj ponownie", + "empty_title": "Jesteś na bieżąco", + "empty_desc": "Tutaj pojawi się nowa aktywność.", + "view_all": "Zobacz wszystkie powiadomienia", + "settings": "Ustawienia powiadomień", + "action_failed": "Nie udało się. Powiadomienia zostały odświeżone." + }, "tab_has_errors": "Zawiera błędy" }, "navigation": { @@ -1855,6 +2134,24 @@ "overview": "Przegląd", "timeline": "Oś czasu" } + }, + "notifications": { + "title": "Powiadomienia", + "desc": "Wszystko, co wydarzyło się pod Twoją nieobecność. Otwarcie tej strony niczego nie oznacza jako przeczytanego.", + "list_label": "Powiadomienia", + "filter_all": "Wszystkie", + "filter_unread": "Nieprzeczytane", + "all_categories": "Wszystkie kategorie", + "category_label": "Filtruj według kategorii", + "mark_all_read": "Oznacz wszystkie jako przeczytane", + "settings": "Ustawienia powiadomień", + "loading": "Wczytywanie…", + "load_more": "Wczytaj więcej", + "load_error": "Nie udało się wczytać powiadomień. Sprawdź połączenie i spróbuj ponownie.", + "retry": "Spróbuj ponownie", + "empty_title": "Na razie pusto", + "empty_unread_title": "Brak nieprzeczytanych powiadomień", + "empty_desc": "Gdy coś będzie wymagało Twojej uwagi, pojawi się tutaj." } } } diff --git a/packages/nodemailer/src/index.ts b/packages/nodemailer/src/index.ts index 1a218d18e..77148a606 100644 --- a/packages/nodemailer/src/index.ts +++ b/packages/nodemailer/src/index.ts @@ -42,11 +42,13 @@ export const NodemailerEmailAdapter = ({ }, ); - await transporter.sendMail({ + const info = await transporter.sendMail({ to, subject, html, }); + + return { id: info.messageId }; }, }; }; diff --git a/packages/resend/src/index.ts b/packages/resend/src/index.ts index 9ecadf5d6..fcde902ff 100644 --- a/packages/resend/src/index.ts +++ b/packages/resend/src/index.ts @@ -10,23 +10,35 @@ export const ResendEmailAdapter = ({ from: string | undefined; }): EmailApiPlugin => { return { - sendEmail: async ({ to, subject, replyTo, metadata, html }) => { + sendEmail: async ({ + to, + subject, + replyTo, + metadata, + html, + idempotencyKey, + }) => { if (!(apiKey && from)) { throw new Error("Missing Resend configuration"); } const resend = new Resend(apiKey); - const { error } = await resend.emails.send({ - from: `${metadata.shortTitle ?? metadata.title} <${from}>`, - to, - subject, - replyTo, - html, - }); + const { data, error } = await resend.emails.send( + { + from: `${metadata.shortTitle ?? metadata.title} <${from}>`, + to, + subject, + replyTo, + html, + }, + idempotencyKey ? { idempotencyKey } : undefined, + ); if (error) { throw new Error(`[${error.name}]: ${error.message}`); } + + return { id: data.id }; }, }; }; diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index d020c9d52..858fa1d83 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -152,6 +152,7 @@ export function VitNodeAPI({ content: vitNodeApiConfig.content, cron: vitNodeApiConfig.cron, events: vitNodeApiConfig.events, + notifications: vitNodeApiConfig.notifications, search: vitNodeApiConfig.search, storage: vitNodeApiConfig.storage, users: vitNodeApiConfig.users, diff --git a/packages/vitnode/src/api/lib/cron.test.ts b/packages/vitnode/src/api/lib/cron.test.ts new file mode 100644 index 000000000..4b56f0e84 --- /dev/null +++ b/packages/vitnode/src/api/lib/cron.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { handleCronJobs } from "./cron"; + +describe("handleCronJobs", () => { + let consoleError: ReturnType; + + beforeEach(() => { + vi.stubEnv("VITNODE_API_URL", "http://localhost:8000"); + vi.stubEnv("CRON_SECRET", "a-real-secret"); + consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + consoleError.mockRestore(); + }); + + it("posts the tick to the API with the cron secret", async () => { + const fetchMock = vi.fn(async () => + Promise.resolve(new Response("ok", { status: 200 })), + ); + vi.stubGlobal("fetch", fetchMock); + + await handleCronJobs(); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:8000/api/@vitnode/core/cron", + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "Bearer a-real-secret", + }), + method: "POST", + }), + ); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it("reports a tick the API rejects instead of dropping it", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Promise.resolve( + new Response("CRON_SECRET is still the built-in default", { + status: 403, + }), + ), + ), + ); + + await handleCronJobs(); + + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("403 CRON_SECRET is still the built-in default"), + ); + }); + + it("reports an unreachable API without throwing", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Promise.reject(new Error("connect ECONNREFUSED 127.0.0.1:8000")), + ), + ); + + await expect(handleCronJobs()).resolves.toBeUndefined(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("ECONNREFUSED"), + ); + }); +}); diff --git a/packages/vitnode/src/api/lib/cron.ts b/packages/vitnode/src/api/lib/cron.ts index 4a99a7556..932777285 100644 --- a/packages/vitnode/src/api/lib/cron.ts +++ b/packages/vitnode/src/api/lib/cron.ts @@ -45,6 +45,13 @@ export function collectCronJobs( ); } +const reportFailedCronTick = (reason: string) => { + // eslint-disable-next-line no-console + console.error( + `\x1b[34m[VitNode]\x1b[0m \x1b[31mCron tick failed\x1b[0m: ${reason}`, + ); +}; + export const handleCronJobs = async () => { const url = new URL("/api/@vitnode/core/cron", CONFIG.api.origin); const headers: HeadersInit = { @@ -55,8 +62,18 @@ export const handleCronJobs = async () => { headers.authorization = `Bearer ${CONFIG.cronJobSecret}`; } - await fetch(url.toString(), { - method: "POST", - headers, - }); + try { + const response = await fetch(url.toString(), { + method: "POST", + headers, + }); + + if (!response.ok) { + reportFailedCronTick(`${response.status} ${await response.text()}`); + } + } catch (error) { + reportFailedCronTick( + error instanceof Error ? error.message : String(error), + ); + } }; diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts index 4de66b35f..53ea66444 100644 --- a/packages/vitnode/src/api/lib/module.ts +++ b/packages/vitnode/src/api/lib/module.ts @@ -6,6 +6,7 @@ import type { AnyContentTypeDefinition } from "@/content/types"; import type { SearchIndexer } from "../models/search"; import type { BuildCronReturn } from "./cron"; import type { BuildEventListenerReturn } from "./events"; +import type { AnyNotificationTypeDefinition } from "./notifications/registry"; import type { BuildQueueTaskReturn } from "./queue"; import type { Route } from "./route"; import type { BuildWebSocketReturn } from "./websocket"; @@ -28,6 +29,7 @@ export interface BaseBuildModuleReturn< hono: OpenAPIHono; modules?: BaseBuildModuleReturn

[]; name: M; + notificationTypes?: AnyNotificationTypeDefinition[]; pluginId: P; publicContentTypes?: AnyContentTypeDefinition[]; queueTasks: BuildQueueTaskReturn[]; @@ -57,6 +59,7 @@ export function buildModule< modules, contentModels, contentTypes, + notificationTypes, publicContentTypes, cronJobs = [], events = [], @@ -70,6 +73,7 @@ export function buildModule< events?: BuildEventListenerReturn[]; modules?: Modules; name: M; + notificationTypes?: AnyNotificationTypeDefinition[]; pluginId: P; publicContentTypes?: AnyContentTypeDefinition[]; queueTasks?: BuildQueueTaskReturn[]; @@ -101,6 +105,7 @@ export function buildModule< contentTypes, cronJobs, events, + notificationTypes, publicContentTypes, queueTasks, searchIndexers, diff --git a/packages/vitnode/src/api/lib/notifications/core-types.ts b/packages/vitnode/src/api/lib/notifications/core-types.ts new file mode 100644 index 000000000..1fd6ee249 --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/core-types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { buildNotificationType } from "./registry"; + +export const adminMessageNotification = buildNotificationType({ + id: "core.admin_message", + version: 1, + schema: z.object({ + description: z.string().max(1000).optional(), + title: z.string().min(1).max(200), + }), + category: "system", + label: "core.notifications.types.admin_message.label", + description: "core.notifications.types.admin_message.description", + defaults: { email: "none", inApp: true }, + present: ({ data }) => ({ body: data.description, title: data.title }), +}); + +export const coreNotificationTypes = [adminMessageNotification]; diff --git a/packages/vitnode/src/api/lib/notifications/digest-period.test.ts b/packages/vitnode/src/api/lib/notifications/digest-period.test.ts new file mode 100644 index 000000000..334b4b9d5 --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/digest-period.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { latestEndedDigestPeriod, zonedHourToUtc } from "./digest-period"; + +const daily = (now: string, timeZone: string, hour = 8) => + latestEndedDigestPeriod({ + hour, + mode: "daily", + now: new Date(now), + timeZone, + weekday: 1, + }); + +describe("zonedHourToUtc", () => { + it("follows the zone's offset on either side of DST", () => { + expect( + zonedHourToUtc({ day: 15, month: 1, year: 2026 }, 8, "Europe/Warsaw"), + ).toEqual(new Date("2026-01-15T07:00:00Z")); + expect( + zonedHourToUtc({ day: 15, month: 7, year: 2026 }, 8, "Europe/Warsaw"), + ).toEqual(new Date("2026-07-15T06:00:00Z")); + }); + + it("moves Warsaw's skipped 02:00 on 29 March 2026 to the end of the gap", () => { + expect( + zonedHourToUtc({ day: 29, month: 3, year: 2026 }, 2, "Europe/Warsaw"), + ).toEqual(new Date("2026-03-29T01:00:00Z")); + }); + + it("picks the first of New York's repeated 01:00 on 1 November 2026", () => { + expect( + zonedHourToUtc({ day: 1, month: 11, year: 2026 }, 1, "America/New_York"), + ).toEqual(new Date("2026-11-01T05:00:00Z")); + }); +}); + +describe("latestEndedDigestPeriod", () => { + it("covers the 23-hour day when clocks spring forward", () => { + const period = daily("2026-03-29T10:00:00Z", "Europe/Warsaw"); + + expect(period).toEqual({ + end: new Date("2026-03-29T06:00:00Z"), + key: "2026-03-29", + start: new Date("2026-03-28T07:00:00Z"), + }); + }); + + it("covers the 25-hour day when clocks fall back", () => { + const period = daily("2026-10-25T12:00:00Z", "Europe/Warsaw"); + + expect(period.end.getTime() - period.start.getTime()).toBe( + 25 * 60 * 60 * 1000, + ); + expect(period.key).toBe("2026-10-25"); + }); + + it("uses yesterday's period until today's send hour has passed", () => { + expect(daily("2026-06-10T05:59:00Z", "Europe/Warsaw").key).toBe( + "2026-06-09", + ); + expect(daily("2026-06-10T06:00:00Z", "Europe/Warsaw").key).toBe( + "2026-06-10", + ); + }); + + it("lands on the local date, not the UTC one (23:30 UTC on the 9th is the 10th in Tokyo)", () => { + expect(daily("2026-06-09T23:30:00Z", "Asia/Tokyo").key).toBe("2026-06-10"); + }); + + it("ends weekly periods on the chosen weekday, a week apart", () => { + const thursdayNoonUtc = new Date("2026-10-08T12:00:00Z"); + const period = latestEndedDigestPeriod({ + hour: 9, + mode: "weekly", + now: thursdayNoonUtc, + timeZone: "UTC", + weekday: 1, + }); + + expect(period).toEqual({ + end: new Date("2026-10-05T09:00:00Z"), + key: "2026-10-05", + start: new Date("2026-09-28T09:00:00Z"), + }); + }); +}); diff --git a/packages/vitnode/src/api/lib/notifications/digest-period.ts b/packages/vitnode/src/api/lib/notifications/digest-period.ts new file mode 100644 index 000000000..38246a5f2 --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/digest-period.ts @@ -0,0 +1,160 @@ +export type DigestMode = "daily" | "weekly"; + +export interface LocalDate { + day: number; + month: number; + year: number; +} + +export interface DigestPeriod { + end: Date; + key: string; + start: Date; +} + +const formatters = new Map(); + +const formatterFor = (timeZone: string): Intl.DateTimeFormat => { + let formatter = formatters.get(timeZone); + if (!formatter) { + formatter = new Intl.DateTimeFormat("en-US", { + day: "numeric", + hour: "numeric", + hourCycle: "h23", + minute: "numeric", + month: "numeric", + second: "numeric", + timeZone, + weekday: "short", + year: "numeric", + }); + formatters.set(timeZone, formatter); + } + + return formatter; +}; + +export const isValidTimeZone = (timeZone: string): boolean => { + try { + formatterFor(timeZone); + + return true; + } catch { + return false; + } +}; + +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +interface WallClock extends LocalDate { + hour: number; + minute: number; + second: number; + weekday: number; +} + +const wallClockAt = (instant: Date, timeZone: string): WallClock => { + const parts = Object.fromEntries( + formatterFor(timeZone) + .formatToParts(instant) + .map(part => [part.type, part.value]), + ); + + return { + day: Number(parts.day), + hour: Number(parts.hour), + minute: Number(parts.minute), + month: Number(parts.month), + second: Number(parts.second), + weekday: WEEKDAYS.indexOf(parts.weekday ?? "Sun"), + year: Number(parts.year), + }; +}; + +const offsetAt = (instant: number, timeZone: string): number => { + const wall = wallClockAt(new Date(instant), timeZone); + const asUtc = Date.UTC( + wall.year, + wall.month - 1, + wall.day, + wall.hour, + wall.minute, + wall.second, + ); + + return asUtc - Math.floor(instant / 1000) * 1000; +}; + +export const zonedHourToUtc = ( + date: LocalDate, + hour: number, + timeZone: string, +): Date => { + const wall = Date.UTC(date.year, date.month - 1, date.day, hour); + const before = offsetAt(wall - 12 * 60 * 60 * 1000, timeZone); + const after = offsetAt(wall + 12 * 60 * 60 * 1000, timeZone); + + const [earliestMatchingInstant] = [ + wall - Math.max(before, after), + wall - Math.min(before, after), + ] + .filter(instant => offsetAt(instant, timeZone) === wall - instant) + .sort((a, b) => a - b); + + if (earliestMatchingInstant !== undefined) { + return new Date(earliestMatchingInstant); + } + + const instantClocksJumpForwardTo = wall - before; + + return new Date(instantClocksJumpForwardTo); +}; + +const addDays = (date: LocalDate, days: number): LocalDate => { + const shifted = new Date( + Date.UTC(date.year, date.month - 1, date.day + days), + ); + + return { + day: shifted.getUTCDate(), + month: shifted.getUTCMonth() + 1, + year: shifted.getUTCFullYear(), + }; +}; + +const formatLocalDate = (date: LocalDate): string => + `${date.year}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`; + +export const latestEndedDigestPeriod = ({ + hour, + mode, + now, + timeZone, + weekday, +}: { + hour: number; + mode: DigestMode; + now: Date; + timeZone: string; + weekday: number; +}): DigestPeriod => { + const today = wallClockAt(now, timeZone); + let date: LocalDate = today; + + if (mode === "weekly") { + date = addDays(today, -((today.weekday - weekday + 7) % 7)); + } + + const step = mode === "daily" ? 1 : 7; + let end = zonedHourToUtc(date, hour, timeZone); + if (end.getTime() > now.getTime()) { + date = addDays(date, -step); + end = zonedHourToUtc(date, hour, timeZone); + } + + return { + end, + key: formatLocalDate(date), + start: zonedHourToUtc(addDays(date, -step), hour, timeZone), + }; +}; diff --git a/packages/vitnode/src/api/lib/notifications/preferences.test.ts b/packages/vitnode/src/api/lib/notifications/preferences.test.ts new file mode 100644 index 000000000..7fb3d89c2 --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/preferences.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { resolveNotificationChannels } from "./preferences"; +import { buildNotificationType } from "./registry"; + +const type = buildNotificationType({ + category: "social", + defaults: { email: "daily", inApp: true }, + email: true, + id: "blog.comment", + label: "x", + present: () => ({ title: "t" }), + schema: z.object({}), + version: 1, +}); +const mandatory = { ...type, mandatory: true }; +const noEmail = { + ...type, + defaults: { email: "none" as const, inApp: true }, + email: false, +}; + +const resolve = ( + args: Partial[0]>, +) => + resolveNotificationChannels({ + definition: type, + emailConfigured: true, + ...args, + }); + +describe("resolveNotificationChannels", () => { + it("falls back from the user, to the installation, to the type", () => { + expect(resolve({})).toEqual({ email: "daily", inApp: true, push: true }); + expect(resolve({ policy: { email: "weekly", inApp: false } })).toEqual({ + email: "weekly", + inApp: false, + push: true, + }); + expect( + resolve({ + policy: { email: "weekly", inApp: false }, + preference: { email: "immediate", inApp: true }, + }), + ).toEqual({ email: "immediate", inApp: true, push: true }); + }); + + it("lets installation policy beat everything but mandatory types", () => { + expect( + resolve({ policy: { enabled: false }, preference: { inApp: true } }), + ).toEqual({ + email: "none", + inApp: false, + push: false, + }); + expect( + resolve({ + definition: mandatory, + policy: { enabled: false }, + }), + ).toEqual({ email: "daily", inApp: true, push: true }); + }); + + it("only offers email where the type, installation and policy all allow it", () => { + expect( + resolve({ emailConfigured: false, preference: { email: "immediate" } }) + .email, + ).toBe("none"); + expect(resolve({ policy: { allowEmail: false } }).email).toBe("none"); + expect( + resolve({ definition: noEmail, preference: { email: "daily" } }).email, + ).toBe("none"); + }); + + it("keeps email independent from the in-app choice", () => { + expect(resolve({ preference: { inApp: false } })).toEqual({ + email: "daily", + inApp: false, + push: true, + }); + }); + + it("ignores the member's own choice when the type is locked for members", () => { + expect( + resolve({ + policy: { email: "weekly", inApp: false, memberCanEdit: false }, + preference: { email: "immediate", inApp: true }, + }), + ).toEqual({ email: "weekly", inApp: false, push: true }); + }); + + it("keeps a type out of the notification list when in-app is disabled", () => { + expect( + resolve({ + policy: { allowInApp: false }, + preference: { inApp: true }, + }), + ).toEqual({ email: "daily", inApp: false, push: true }); + expect( + resolve({ definition: mandatory, policy: { allowInApp: false } }).inApp, + ).toBe(true); + }); + + it("offers push unless the installation disables it, and lets members turn it off", () => { + expect(resolve({}).push).toBe(true); + expect(resolve({ preference: { push: false } }).push).toBe(false); + expect( + resolve({ policy: { allowPush: false }, preference: { push: true } }) + .push, + ).toBe(false); + expect( + resolve({ policy: { memberCanEdit: false }, preference: { push: false } }) + .push, + ).toBe(true); + }); +}); diff --git a/packages/vitnode/src/api/lib/notifications/preferences.ts b/packages/vitnode/src/api/lib/notifications/preferences.ts new file mode 100644 index 000000000..2fa763d45 --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/preferences.ts @@ -0,0 +1,143 @@ +import type { NotificationTypePreference } from "@/database/notifications"; +import type { NotificationEmailMode } from "@/lib/notifications/types"; + +import type { AnyNotificationTypeDefinition } from "./registry"; + +export interface NotificationTypePolicy { + allowEmail?: boolean; + allowInApp?: boolean; + allowPush?: boolean; + email?: NotificationEmailMode; + enabled?: boolean; + inApp?: boolean; + memberCanEdit?: boolean; +} + +export interface NotificationGlobalSettings { + paused: boolean; +} + +export const NOTIFICATION_DIGEST_SCHEDULE = { hour: 8, weekday: 1 } as const; + +export const DEFAULT_NOTIFICATION_SETTINGS: NotificationGlobalSettings = { + paused: false, +}; + +export interface NotificationWorkerSettings { + emailBatchSize: number; + emailConcurrency: number; + fanoutBatchSize: number; + retentionDays: number; +} + +const NOTIFICATION_WORKER_LIMITS = { + emailBatchSize: { default: 50, max: 500, min: 1 }, + emailConcurrency: { default: 4, max: 20, min: 1 }, + fanoutBatchSize: { default: 500, max: 5000, min: 10 }, + retentionDays: { default: 90, max: 3650, min: 1 }, +} as const satisfies Record< + keyof NotificationWorkerSettings, + { default: number; max: number; min: number } +>; + +export const resolveNotificationWorkerSettings = ( + config: Partial = {}, +): NotificationWorkerSettings => { + const resolve = (key: keyof NotificationWorkerSettings) => { + const { default: fallback, max, min } = NOTIFICATION_WORKER_LIMITS[key]; + const value = config[key]; + + return typeof value === "number" && Number.isInteger(value) + ? Math.min(max, Math.max(min, value)) + : fallback; + }; + + return { + emailBatchSize: resolve("emailBatchSize"), + emailConcurrency: resolve("emailConcurrency"), + fanoutBatchSize: resolve("fanoutBatchSize"), + retentionDays: resolve("retentionDays"), + }; +}; + +export interface NotificationChannels { + email: NotificationEmailMode; + inApp: boolean; + push: boolean; +} + +const OFF: NotificationChannels = { email: "none", inApp: false, push: false }; + +export const isNotificationInAppAvailable = ({ + definition, + policy, +}: { + definition: AnyNotificationTypeDefinition; + policy?: NotificationTypePolicy; +}): boolean => !!definition.mandatory || policy?.allowInApp !== false; + +export const isNotificationPushAvailable = ( + policy?: NotificationTypePolicy, +): boolean => policy?.allowPush !== false; + +export const isNotificationLockedForMembers = ({ + definition, + policy, +}: { + definition: AnyNotificationTypeDefinition; + policy?: NotificationTypePolicy; +}): boolean => !!definition.mandatory || policy?.memberCanEdit === false; + +export const isNotificationEmailAvailable = ({ + definition, + emailConfigured, + policy, +}: { + definition: AnyNotificationTypeDefinition; + emailConfigured: boolean; + policy?: NotificationTypePolicy; +}): boolean => + emailConfigured && !!definition.email && policy?.allowEmail !== false; + +export const resolveNotificationChannels = ({ + definition, + emailConfigured, + policy, + preference, +}: { + definition: AnyNotificationTypeDefinition; + emailConfigured: boolean; + policy?: NotificationTypePolicy; + preference?: NotificationTypePreference; +}): NotificationChannels => { + if (policy?.enabled === false && !definition.mandatory) return OFF; + + const emailAvailable = isNotificationEmailAvailable({ + definition, + emailConfigured, + policy, + }); + const defaultEmail = policy?.email ?? definition.defaults.email; + + const pushAvailable = isNotificationPushAvailable(policy); + + if (definition.mandatory) { + return { + email: emailAvailable ? defaultEmail : "none", + inApp: true, + push: pushAvailable, + }; + } + + const choice = isNotificationLockedForMembers({ definition, policy }) + ? undefined + : preference; + + return { + email: emailAvailable ? (choice?.email ?? defaultEmail) : "none", + inApp: + isNotificationInAppAvailable({ definition, policy }) && + (choice?.inApp ?? policy?.inApp ?? definition.defaults.inApp), + push: pushAvailable && (choice?.push ?? true), + }; +}; diff --git a/packages/vitnode/src/api/lib/notifications/registry.test.ts b/packages/vitnode/src/api/lib/notifications/registry.test.ts new file mode 100644 index 000000000..36856f843 --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/registry.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + buildNotificationType, + createNotificationRegistry, + parseStoredNotificationData, +} from "./registry"; + +const base = { + category: "social", + defaults: { email: "none" as const, inApp: true }, + label: "x.label", + present: () => ({ title: "t" }), + schema: z.object({ n: z.number() }), + version: 1, +}; + +describe("buildNotificationType", () => { + it("accepts a plugin-scoped id", () => { + expect(buildNotificationType({ ...base, id: "blog.comment" }).id).toBe( + "blog.comment", + ); + }); + + it.each(["comment", "Blog.Comment", "blog..comment", "blog comment"])( + "rejects the id %s", + id => { + expect(() => buildNotificationType({ ...base, id })).toThrow( + /Invalid notification type id/, + ); + }, + ); + + it("refuses an email default without an email presentation", () => { + expect(() => + buildNotificationType({ + ...base, + defaults: { email: "daily", inApp: true }, + id: "blog.comment", + }), + ).toThrow(/declares no email presentation/); + }); + + it("bounds the grouping window", () => { + expect(() => + buildNotificationType({ + ...base, + grouping: { windowMinutes: 0 }, + id: "blog.comment", + }), + ).toThrow(/grouping window/); + }); +}); + +describe("createNotificationRegistry", () => { + const comment = buildNotificationType({ ...base, id: "blog.comment" }); + + it("refuses the same id from two plugins", () => { + expect(() => + createNotificationRegistry([ + { definition: comment, pluginId: "@acme/a" }, + { definition: { ...comment }, pluginId: "@acme/b" }, + ]), + ).toThrow(/Duplicate notification type "blog.comment"/); + }); +}); + +describe("parseStoredNotificationData", () => { + const v2 = buildNotificationType({ + ...base, + id: "blog.comment", + migrate: (data, from) => + from === 1 ? { n: Number((data as { count: string }).count) } : data, + schema: z.object({ n: z.number() }), + version: 2, + }); + + it("upgrades data stored by an older version", () => { + expect(parseStoredNotificationData(v2, { count: "3" }, 1)).toEqual({ + n: 3, + }); + }); + + it("gives up on data it cannot read, instead of rendering garbage", () => { + expect(parseStoredNotificationData(v2, { n: "nope" }, 2)).toBeNull(); + expect(parseStoredNotificationData(v2, { n: 1 }, 3)).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/api/lib/notifications/registry.ts b/packages/vitnode/src/api/lib/notifications/registry.ts new file mode 100644 index 000000000..b7fa16313 --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/registry.ts @@ -0,0 +1,213 @@ +import type { Context } from "hono"; +import type { z } from "zod"; + +import type { EnvVitNode } from "@/api/middlewares/global.middleware"; +import type { Translator } from "@/api/models/i18n"; +import type { + NotificationEmailMode, + NotificationSubject, +} from "@/lib/notifications/types"; + +import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types"; + +export interface NotificationPresentation { + body?: string; + target?: null | string; + title: string; +} + +export interface NotificationEmailPresentation { + actionLabel?: string; + body?: string; + subject: string; + title: string; +} + +export interface NotificationActorPreview { + id: number; + name: string; +} + +export interface NotificationPresentArgs { + actorCount: number; + actors: NotificationActorPreview[]; + data: TData; + eventCount: number; + locale: string; + subject: NotificationSubject | null; + t: Translator; +} + +export interface NotificationAccessArgs { + c: Context; + data: TData; + subject: NotificationSubject | null; + userIds: number[]; +} + +export interface NotificationGroupingConfig { + key?: (args: { + data: TData; + subject: NotificationSubject | null; + }) => null | string; + windowMinutes: number; +} + +export interface NotificationTypeDefinition> { + access?: ( + args: NotificationAccessArgs, + ) => number[] | Promise; + category: string; + defaults: { email: NotificationEmailMode; inApp: boolean }; + description?: string; + email?: + | ((args: NotificationPresentArgs) => NotificationEmailPresentation) + | boolean; + grouping?: NotificationGroupingConfig; + id: string; + label: string; + mandatory?: boolean; + migrate?: (data: unknown, fromVersion: number) => unknown; + present: (args: NotificationPresentArgs) => NotificationPresentation; + schema: z.ZodType; + subjectType?: string; + version: number; +} + +export type AnyNotificationTypeDefinition = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + NotificationTypeDefinition; + +export interface RegisteredNotificationType { + definition: AnyNotificationTypeDefinition; + pluginId: string; +} + +const TYPE_ID_PATTERN = /^[a-z0-9_-]+(\.[a-z0-9_-]+)+$/; +const MAX_TYPE_ID_LENGTH = 100; +const CATEGORY_PATTERN = /^[a-z0-9_-]{1,50}$/; +export const SUBJECT_TYPE_PATTERN = /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/; +const MAX_GROUPING_WINDOW_MINUTES = 60 * 24 * 7; + +export class NotificationRegistryError extends Error { + constructor(message: string) { + super(`[Notifications] ${message}`); + this.name = "NotificationRegistryError"; + } +} + +export function buildNotificationType( + definition: NotificationTypeDefinition, +): NotificationTypeDefinition { + if ( + !TYPE_ID_PATTERN.test(definition.id) || + definition.id.length > MAX_TYPE_ID_LENGTH + ) { + throw new NotificationRegistryError( + `Invalid notification type id "${definition.id}". Use a plugin-scoped, dot-separated id such as "blog.comment" (lowercase letters, digits, "_" and "-").`, + ); + } + + if (!Number.isInteger(definition.version) || definition.version < 1) { + throw new NotificationRegistryError( + `Notification type "${definition.id}" needs an integer version of 1 or more.`, + ); + } + + if (!CATEGORY_PATTERN.test(definition.category)) { + throw new NotificationRegistryError( + `Notification type "${definition.id}" has an invalid category "${definition.category}".`, + ); + } + + if (!NOTIFICATION_EMAIL_MODES.includes(definition.defaults.email)) { + throw new NotificationRegistryError( + `Notification type "${definition.id}" has an unknown default email mode "${definition.defaults.email}".`, + ); + } + + if (!definition.email && definition.defaults.email !== "none") { + throw new NotificationRegistryError( + `Notification type "${definition.id}" defaults to "${definition.defaults.email}" email but declares no email presentation. Set "email: true" or default to "none".`, + ); + } + + if ( + definition.subjectType !== undefined && + !SUBJECT_TYPE_PATTERN.test(definition.subjectType) + ) { + throw new NotificationRegistryError( + `Notification type "${definition.id}" has an invalid subject type "${definition.subjectType}".`, + ); + } + + if (definition.grouping) { + const { windowMinutes } = definition.grouping; + if ( + !Number.isInteger(windowMinutes) || + windowMinutes < 1 || + windowMinutes > MAX_GROUPING_WINDOW_MINUTES + ) { + throw new NotificationRegistryError( + `Notification type "${definition.id}" needs a grouping window between 1 and ${MAX_GROUPING_WINDOW_MINUTES} minutes.`, + ); + } + } + + return definition; +} + +export const validateNotificationTypes = ( + entries: readonly RegisteredNotificationType[], +): void => { + const seen = new Map(); + + for (const entry of entries) { + const owner = seen.get(entry.definition.id); + if (owner !== undefined) { + throw new NotificationRegistryError( + `Duplicate notification type "${entry.definition.id}": registered by both "${owner}" and "${entry.pluginId}".`, + ); + } + + seen.set(entry.definition.id, entry.pluginId); + } +}; + +export interface NotificationRegistry { + get: (type: string) => RegisteredNotificationType | undefined; + list: RegisteredNotificationType[]; +} + +export const createNotificationRegistry = ( + entries: readonly RegisteredNotificationType[], +): NotificationRegistry => { + validateNotificationTypes(entries); + const list = [...entries]; + const byId = new Map(list.map(entry => [entry.definition.id, entry])); + + return { + get: type => byId.get(type), + list, + }; +}; + +export const parseStoredNotificationData = ( + definition: AnyNotificationTypeDefinition, + data: unknown, + version: number, +): unknown => { + let value = data; + if (version !== definition.version) { + if (!definition.migrate || version > definition.version) return null; + try { + value = definition.migrate(data, version); + } catch { + return null; + } + } + + const parsed = definition.schema.safeParse(value); + + return parsed.success ? parsed.data : null; +}; diff --git a/packages/vitnode/src/api/lib/notifications/safe.test.ts b/packages/vitnode/src/api/lib/notifications/safe.test.ts new file mode 100644 index 000000000..7380c444a --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/safe.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + plainNotificationText, + safeNotificationTarget, + sanitizeDeliveryError, +} from "./safe"; + +describe("safeNotificationTarget", () => { + it.each([ + ["/blog/hello", "/blog/hello"], + ["/search?q=a#top", "/search?q=a#top"], + ["/a/../b", "/b"], + ])("keeps the on-site path %s", (input, expected) => { + expect(safeNotificationTarget(input)).toBe(expected); + }); + + it.each([ + "https://evil.example/x", + "//evil.example/x", + "/\\evil.example", + "javascript:alert(1)", + "blog/hello", + "/x\u0000y", + "", + 42, + null, + ])("drops %s", input => { + expect(safeNotificationTarget(input)).toBeNull(); + }); +}); + +describe("plainNotificationText", () => { + it("collapses whitespace, strips control characters and bounds the length", () => { + expect(plainNotificationText(" a\n\n b\u0007 ")).toBe("a b"); + expect(plainNotificationText("x".repeat(600))).toHaveLength(500); + expect(plainNotificationText(undefined)).toBe(""); + }); +}); + +describe("sanitizeDeliveryError", () => { + it("redacts addresses and credentials", () => { + expect( + sanitizeDeliveryError( + new Error( + "550 bob@example.com rejected, api_key=sk_live_1 Bearer abc.def", + ), + ), + ).toBe("550 [email] rejected, api_key=[redacted] Bearer [redacted]"); + }); +}); diff --git a/packages/vitnode/src/api/lib/notifications/safe.ts b/packages/vitnode/src/api/lib/notifications/safe.ts new file mode 100644 index 000000000..5b2cdb711 --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/safe.ts @@ -0,0 +1,55 @@ +const MAX_TARGET_LENGTH = 2048; +const MAX_TEXT_LENGTH = 500; +const MAX_ERROR_LENGTH = 500; + +export const safeNotificationTarget = (target: unknown): null | string => { + if (typeof target !== "string") return null; + if (target.length === 0 || target.length > MAX_TARGET_LENGTH) return null; + if (!target.startsWith("/")) return null; + if (target.startsWith("//") || target.startsWith("/\\")) return null; + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u001f\u007f\\]/.test(target)) return null; + + try { + const base = "https://notifications.invalid"; + const url = new URL(target, base); + if (url.origin !== base) return null; + + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return null; + } +}; + +export const plainNotificationText = (value: unknown): string => { + if (typeof value !== "string") return ""; + + return ( + value + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, MAX_TEXT_LENGTH) + ); +}; + +export const sanitizeDeliveryError = (error: unknown): string => { + const raw = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Unknown error"; + + return raw + .replace(/[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+/g, "[email]") + .replace( + /(key|token|secret|password|authorization)=[^\s&]+/gi, + "$1=[redacted]", + ) + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [redacted]") + .replace(/\s+/g, " ") + .trim() + .slice(0, MAX_ERROR_LENGTH); +}; diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index 789b236d2..8231705f8 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -21,6 +21,7 @@ import type { CronJobConfig } from "./cron"; import type { RegisteredEditablePage } from "./editable-pages"; import type { EventListenerConfig } from "./events"; import type { BaseBuildModuleReturn, BuildModuleReturn } from "./module"; +import type { AnyNotificationTypeDefinition } from "./notifications/registry"; import type { PermissionStaffConfig } from "./permission-staff"; import type { QueueTaskConfig } from "./queue"; import type { WebSocketConfig } from "./websocket"; @@ -29,6 +30,7 @@ import { validateSearchIndexers } from "../models/search"; import { checkPluginId } from "./check-plugin-id"; import { registerEditablePage, validateEditablePages } from "./editable-pages"; import { collectNavigationPresets } from "./navigation-presets"; +import { validateNotificationTypes } from "./notifications/registry"; import { applyModuleTags } from "./openapi-tags"; export type { ApiPluginContract }; @@ -48,6 +50,7 @@ export interface BuildPluginApiReturn< messages?: LocaleMessagesMap; modules: Modules; navigation?: NavigationPresetDeclaration[]; + notificationTypes?: AnyNotificationTypeDefinition[]; openApiTags?: string[]; permissionStaff?: PermissionStaffConfig; pluginId: P; @@ -72,6 +75,7 @@ export function buildApiPlugin< messages, modules = [] as unknown as Modules, navigation, + notificationTypes, permissionStaff, searchIndexers, }: { @@ -80,6 +84,7 @@ export function buildApiPlugin< messages?: LocaleMessagesMap; modules?: Modules; navigation?: NavigationPresetDeclaration[]; + notificationTypes?: AnyNotificationTypeDefinition[]; permissionStaff?: PermissionStaffConfig; pluginId: P; searchIndexers?: SearchIndexer[]; @@ -109,6 +114,9 @@ export function buildApiPlugin< const cronJobs: BuildPluginApiReturn["cronJobs"] = []; const events: BuildPluginApiReturn["events"] = []; const indexers: SearchIndexer[] = [...(searchIndexers ?? [])]; + const pluginNotificationTypes: AnyNotificationTypeDefinition[] = [ + ...(notificationTypes ?? []), + ]; const openApiTags: string[] = []; const queueTasks: BuildPluginApiReturn["queueTasks"] = []; const webSockets: BuildPluginApiReturn["webSockets"] = []; @@ -121,6 +129,9 @@ export function buildApiPlugin< contentTypes.push(...collectContentTypes(handler)); publicContentTypes.push(...collectPublicContentTypes(handler)); indexers.push(...collectSearchIndexers(handler)); + pluginNotificationTypes.push( + ...collectModuleTree(handler, m => m.notificationTypes), + ); handler.cronJobs?.forEach(cron => { cronJobs.push({ ...cron, module: handler.name }); @@ -145,6 +156,10 @@ export function buildApiPlugin< validateSearchIndexers(indexers.map(indexer => ({ ...indexer, pluginId }))); + validateNotificationTypes( + pluginNotificationTypes.map(definition => ({ definition, pluginId })), + ); + const publishing = new Map(); for (const definition of [ ...registered.map(entry => entry.definition), @@ -170,6 +185,7 @@ export function buildApiPlugin< cronJobs, events, queueTasks, + notificationTypes: pluginNotificationTypes, searchIndexers: indexers, webSockets, // Every content type contributes can_view/can_create/can_edit/can_delete @@ -218,3 +234,13 @@ function collectSearchIndexers(module: BaseBuildModuleReturn): SearchIndexer[] { ...(module.modules ?? []).flatMap(collectSearchIndexers), ]; } + +function collectModuleTree( + module: BaseBuildModuleReturn, + pick: (module: BaseBuildModuleReturn) => T[] | undefined, +): T[] { + return [ + ...(pick(module) ?? []), + ...(module.modules ?? []).flatMap(child => collectModuleTree(child, pick)), + ]; +} diff --git a/packages/vitnode/src/api/middlewares/cron-auth.middleware.ts b/packages/vitnode/src/api/middlewares/cron-auth.middleware.ts index 29c05d920..47af51b18 100644 --- a/packages/vitnode/src/api/middlewares/cron-auth.middleware.ts +++ b/packages/vitnode/src/api/middlewares/cron-auth.middleware.ts @@ -3,7 +3,7 @@ import type { Context, Next } from "hono"; import { HTTPException } from "hono/http-exception"; import { timingSafeEqual } from "node:crypto"; -import { CONFIG, INSECURE_CRON_SECRETS } from "@/lib/config"; +import { isCronSecretRejected } from "@/lib/config"; /** * Constant-time comparison of two secrets. @@ -53,10 +53,7 @@ export const cronAuthMiddleware = () => { // repository, so anyone could post to this endpoint and run every registered // job. The admin panel flags it, but a warning nobody reads is not a control, // so production refuses the request outright. - if ( - INSECURE_CRON_SECRETS.includes(cronSecret) && - !CONFIG.node_development - ) { + if (isCronSecretRejected(cronSecret)) { throw new HTTPException(403, { message: "Cron access is disabled because CRON_SECRET is still the built-in default. Set CRON_SECRET to a random value.", diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index cbc40ceb9..cdb9b1bb7 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -19,6 +19,7 @@ import { AIModel } from "@/api/models/ai"; import { EmailModel } from "@/api/models/email"; import { EventsModel } from "@/api/models/events"; import { I18nModel } from "@/api/models/i18n"; +import { NotificationsModel } from "@/api/models/notifications"; import { QueueModel } from "@/api/models/queue"; import { assertSearchProviderCapabilities, @@ -42,6 +43,8 @@ import { realtime } from "@/ws/registry"; import type { BuildCronReturn } from "../lib/cron"; import type { RegisteredEditablePage } from "../lib/editable-pages"; import type { EventListenerConfig } from "../lib/events"; +import type { NotificationWorkerSettings } from "../lib/notifications/preferences"; +import type { NotificationRegistry } from "../lib/notifications/registry"; import type { ResolvedPasskeysConfig } from "../lib/passkey-config"; import type { PermissionStaffCatalogEntry } from "../lib/permission-staff"; import type { BuildQueueTaskReturn } from "../lib/queue"; @@ -65,6 +68,8 @@ import { type LoggerMiddlewareType, } from "../lib/logger-middleware"; import { collectNavigationPresets } from "../lib/navigation-presets"; +import { resolveNotificationWorkerSettings } from "../lib/notifications/preferences"; +import { createNotificationRegistry } from "../lib/notifications/registry"; import { resolvePasskeysConfig } from "../lib/passkey-config"; import { normalizePermissionStaffModules } from "../lib/permission-staff"; @@ -98,6 +103,7 @@ export interface EnvVariablesVitNode { phone: null | string; roleId: number; showRealName: boolean; + timeZone: null | string; }; }; adminSessionExpiresAt: Date | null; @@ -149,6 +155,8 @@ export interface EnvVariablesVitNode { }; /** Every prebuilt main-menu item the installed plugins offer. */ navigation: NavigationPreset[]; + notifications?: NotificationRegistry; + notificationWorkers?: NotificationWorkerSettings; permissionStaff: PermissionStaffCatalogEntry[]; /** Which personal-information fields this install offers. */ personalInformationFields: PersonalInformationFields; @@ -165,6 +173,7 @@ export interface EnvVariablesVitNode { i18n: I18nModel; ipAddress: string; log: LoggerMiddlewareType; + notifications: NotificationsModel; plugin: { id: string; }; @@ -191,6 +200,7 @@ export interface EnvVariablesVitNode { phone: null | string; roleId: number; showRealName: boolean; + timeZone: null | string; }; } @@ -206,6 +216,7 @@ export const globalMiddleware = ({ events, plugins, i18n, + notifications, search, storage, users, @@ -221,6 +232,7 @@ export const globalMiddleware = ({ | "email" | "events" | "i18n" + | "notifications" | "plugins" | "search" | "storage" @@ -229,6 +241,7 @@ export const globalMiddleware = ({ Pick & { cacheClient: CacheClient | null; }) => { + const notificationWorkers = resolveNotificationWorkerSettings(notifications); const pluginsMetadata = plugins.map(plugin => ({ id: plugin.pluginId, })); @@ -367,6 +380,15 @@ export const globalMiddleware = ({ const navigationMetadata: NavigationPreset[] = collectNavigationPresets(plugins); + const notificationsMetadata = createNotificationRegistry( + plugins.flatMap(plugin => + (plugin.notificationTypes ?? []).map(definition => ({ + definition, + pluginId: plugin.pluginId, + })), + ), + ); + const passkeysMetadata = resolvePasskeysConfig({ config: authorization?.passkeys, rpNameFallback: metadata.shortTitle ?? metadata.title, @@ -398,6 +420,7 @@ export const globalMiddleware = ({ c.set("email", new EmailModel(c)); c.set("events", new EventsModel(c)); c.set("i18n", new I18nModel(c)); + c.set("notifications", new NotificationsModel(c as Context)); c.set("queue", new QueueModel(c)); c.set("search", new SearchModel(c)); c.set("storage", new StorageModel(c)); @@ -453,6 +476,8 @@ export const globalMiddleware = ({ webSockets: webSocketsMetadata, permissionStaff: permissionStaffMetadata, navigation: navigationMetadata, + notifications: notificationsMetadata, + notificationWorkers, contentModels: contentModelsMetadata, contentRevalidateOrigins: content?.revalidateOrigins, contentTypes: contentTypesMetadata, @@ -475,6 +500,8 @@ export const globalMiddleware = ({ c.set("log", loggerMiddleware(c)); await next(); + + c.get("notifications").flushAfterResponse(); }; }; diff --git a/packages/vitnode/src/api/models/email.ts b/packages/vitnode/src/api/models/email.ts index ccbd008bf..caa948a1b 100644 --- a/packages/vitnode/src/api/models/email.ts +++ b/packages/vitnode/src/api/models/email.ts @@ -29,12 +29,18 @@ interface EmailModelSendArgsWithEmail { export interface EmailApiPlugin { sendEmail: (args: { html: string; + idempotencyKey?: string; metadata: ContextVariableMap["core"]["metadata"]; replyTo?: string; subject: string; text: string; to: string; - }) => Promise; + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + }) => Promise; +} + +export interface EmailSendResult { + id?: string; } export type EmailModelSendArgs = { @@ -131,17 +137,23 @@ export class EmailModel { }; } - async deliver(email: BuiltEmail): Promise { + async deliver( + email: BuiltEmail, + { idempotencyKey }: { idempotencyKey?: string } = {}, + ): Promise { const provider = this.requireProvider(); - await provider.sendEmail({ + const sendResult = await provider.sendEmail({ html: email.html, + idempotencyKey, to: email.to, subject: email.subject, replyTo: email.replyTo, metadata: this.c.get("core").metadata, text: email.text, }); + + return sendResult ?? {}; } async send(args: EmailModelSendArgs): Promise { diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 14dc8d868..15e3cce47 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -25,6 +25,30 @@ export interface VitNodeEvents { "navigation.updated": { navigationId: number; }; + "notifications.deleted_all": { + items: number; + }; + "notifications.emails_cancelled": { + count: number; + }; + "notifications.paused": Record; + "notifications.preferences_reset": { + members: number; + }; + "notifications.preferences_updated": { + typeIds: string[]; + userId: number; + }; + "notifications.read_all": { + items: number; + members: number; + }; + "notifications.resumed": { + requeuedEvents: number; + }; + "notifications.type.updated": { + typeId: string; + }; "role.created": { roleId: number; }; @@ -69,11 +93,24 @@ export interface VitNodeEvents { passkeyId: number; userId: number; }; + "user.password.updated": { + userId: number; + }; + "user.sessions.revoked": { + deviceId: null | number; + sessions: number; + userId: number; + }; "user.sso.linked": { email: string; providerId: string; userId: number; }; + "user.sso.preferences_updated": { + sources: Partial>; + sync: Record; + userId: number; + }; "user.sso.profile_synced": { fields: SsoProfileField[]; providerId: string; diff --git a/packages/vitnode/src/api/models/notifications/admin.ts b/packages/vitnode/src/api/models/notifications/admin.ts new file mode 100644 index 000000000..5e3de8cf6 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/admin.ts @@ -0,0 +1,374 @@ +import { and, count, desc, eq, inArray, like, lt, sql } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import type { NotificationTypePolicy } from "@/api/lib/notifications/preferences"; + +import { + isNotificationEmailAvailable, + isNotificationInAppAvailable, + isNotificationLockedForMembers, +} from "@/api/lib/notifications/preferences"; +import { loadCronHealth } from "@/api/modules/cron/helpers/load-cron-health"; +import { + core_notification_deliveries, + core_notification_events, + core_notification_settings, + core_notification_user_state, + core_notifications, +} from "@/database/notifications"; +import { core_queue } from "@/database/queue"; + +import type { NotificationsContext } from "./shared"; + +import { dispatchEmailDrain } from "./email-queue"; +import { reconcileNotificationCounts, UNREAD_SQL } from "./inbox"; +import { translateOr } from "./preferences"; +import { createTranslatorCache } from "./render"; +import { + getNotificationRegistry, + getNotificationWorkers, + isEmailConfigured, + loadNotificationSettings, + NOTIFICATIONS_PLUGIN_ID, + typeSettingsKey, +} from "./shared"; + +const countBy = (rows: { count: number; key: K }[]) => { + const counts: Partial> = {}; + for (const row of rows) counts[row.key] = row.count; + + return counts; +}; + +export const getNotificationsOverview = async (c: NotificationsContext) => { + const db = c.get("db"); + const core = c.get("core"); + const emailConfigured = isEmailConfigured(c); + + const [ + settings, + { t }, + queueRows, + deliveryRows, + eventRows, + [oldestPending], + cronHealth, + [inbox], + [members], + ] = await Promise.all([ + loadNotificationSettings(db), + createTranslatorCache(c)(c.get("admin")?.user.language), + db + .select({ + count: count(), + key: sql`${core_queue.name} || ':' || ${core_queue.status}`, + }) + .from(core_queue) + .where( + and( + eq(core_queue.pluginId, NOTIFICATIONS_PLUGIN_ID), + like(core_queue.name, "notifications-%"), + ), + ) + .groupBy(core_queue.name, core_queue.status), + db + .select({ count: count(), key: core_notification_deliveries.status }) + .from(core_notification_deliveries) + .groupBy(core_notification_deliveries.status), + db + .select({ count: count(), key: core_notification_events.status }) + .from(core_notification_events) + .groupBy(core_notification_events.status), + db + .select({ createdAt: core_notification_events.createdAt }) + .from(core_notification_events) + .where( + inArray(core_notification_events.status, ["pending", "processing"]), + ) + .orderBy(core_notification_events.createdAt) + .limit(1), + loadCronHealth(db), + db + .select({ + items: count(), + unread: sql`count(*) filter (where ${UNREAD_SQL})`.mapWith( + Number, + ), + }) + .from(core_notifications), + db + .select({ customized: count() }) + .from(core_notification_user_state) + .where(sql`${core_notification_user_state.preferences} <> '{}'::jsonb`), + ]); + + return { + counts: { + customizedMembers: members.customized, + inboxItems: inbox.items, + queuedEmails: countBy(deliveryRows).pending ?? 0, + unreadItems: inbox.unread, + }, + email: { + adapterConfigured: !!core.email?.adapter, + }, + health: { + cronActive: core.hasCronAdapter, + cronStale: cronHealth.stale, + deliveries: countBy(deliveryRows), + events: countBy(eventRows), + oldestPendingEventAt: oldestPending?.createdAt ?? null, + queue: countBy(queueRows), + }, + settings: settings.global, + workers: getNotificationWorkers(c), + types: getNotificationRegistry(c).list.map(({ definition, pluginId }) => { + const policy = settings.policies.get(definition.id) ?? {}; + + return { + category: definition.category, + defaults: definition.defaults, + emailAvailable: isNotificationEmailAvailable({ + definition, + emailConfigured, + policy, + }), + emailSupported: !!definition.email, + grouped: !!definition.grouping, + id: definition.id, + label: translateOr(t, [definition.label], definition.id), + mandatory: !!definition.mandatory, + pluginId, + policy: { + allowEmail: policy.allowEmail ?? true, + allowInApp: isNotificationInAppAvailable({ definition, policy }), + allowPush: policy.allowPush ?? true, + email: policy.email ?? null, + enabled: policy.enabled ?? true, + inApp: policy.inApp ?? null, + memberCanEdit: !isNotificationLockedForMembers({ + definition, + policy, + }), + }, + version: definition.version, + }; + }), + }; +}; + +export const upsertNotificationSetting = async ( + c: NotificationsContext, + key: string, + value: Record, +) => { + await c + .get("db") + .insert(core_notification_settings) + .values({ key, updatedAt: new Date(), value }) + .onConflictDoUpdate({ + set: { updatedAt: new Date(), value }, + target: core_notification_settings.key, + }); +}; + +const POLICY_KEYS = [ + "allowEmail", + "allowInApp", + "allowPush", + "email", + "enabled", + "inApp", + "memberCanEdit", +] as const satisfies readonly (keyof NotificationTypePolicy)[]; + +export const updateNotificationTypePolicy = async ( + c: NotificationsContext, + typeId: string, + patch: NotificationTypePolicy, +): Promise => { + const registered = getNotificationRegistry(c).get(typeId); + if (!registered) throw new HTTPException(404); + if (patch.email && patch.email !== "none" && !registered.definition.email) { + throw new HTTPException(400, { + message: "This type has no email channel.", + }); + } + + const { policies } = await loadNotificationSettings(c.get("db")); + const merged: NotificationTypePolicy = { ...policies.get(typeId) }; + for (const key of POLICY_KEYS) { + if (patch[key] !== undefined) Object.assign(merged, { [key]: patch[key] }); + } + + await upsertNotificationSetting(c, typeSettingsKey(typeId), { ...merged }); + await c.get("events").emit("notifications.type.updated", { typeId }); + + return merged; +}; + +export const listNotificationDeliveries = async ( + c: NotificationsContext, + { + beforeId, + limit, + status, + }: { + beforeId?: number; + limit: number; + status?: (typeof core_notification_deliveries.$inferSelect)["status"]; + }, +) => { + const rows = await c + .get("db") + .select({ + attempts: core_notification_deliveries.attempts, + availableAt: core_notification_deliveries.availableAt, + createdAt: core_notification_deliveries.createdAt, + id: core_notification_deliveries.id, + itemCount: core_notification_deliveries.itemCount, + lastError: core_notification_deliveries.lastError, + maxAttempts: core_notification_deliveries.maxAttempts, + mode: core_notification_deliveries.mode, + providerMessageId: core_notification_deliveries.providerMessageId, + sentAt: core_notification_deliveries.sentAt, + skipReason: core_notification_deliveries.skipReason, + status: core_notification_deliveries.status, + type: core_notification_events.type, + updatedAt: core_notification_deliveries.updatedAt, + userId: core_notification_deliveries.userId, + }) + .from(core_notification_deliveries) + .leftJoin( + core_notification_events, + eq(core_notification_events.id, core_notification_deliveries.eventId), + ) + .where( + and( + status ? eq(core_notification_deliveries.status, status) : undefined, + beforeId ? lt(core_notification_deliveries.id, beforeId) : undefined, + ), + ) + .orderBy(desc(core_notification_deliveries.id)) + .limit(limit + 1); + + return { + items: rows.slice(0, limit), + nextCursor: rows.length > limit ? (rows[limit - 1]?.id ?? null) : null, + }; +}; + +export const retryNotificationDelivery = async ( + c: NotificationsContext, + deliveryId: number, +): Promise => { + const db = c.get("db"); + const [updated] = await db + .update(core_notification_deliveries) + .set({ + attempts: 0, + availableAt: new Date(), + lastError: null, + status: "pending", + updatedAt: new Date(), + }) + .where( + and( + eq(core_notification_deliveries.id, deliveryId), + eq(core_notification_deliveries.status, "failed"), + ), + ) + .returning({ id: core_notification_deliveries.id }); + if (!updated) { + throw new HTTPException(409, { + message: "Only failed deliveries can be retried.", + }); + } + + await dispatchEmailDrain(c); +}; + +export const queueNotificationTestEmail = async ( + c: NotificationsContext, + adminUserId: number, +): Promise<{ deliveryId: number }> => { + if (!isEmailConfigured(c)) { + throw new HTTPException(400, { + message: "Notification email is not configured.", + }); + } + + const now = new Date(); + const [delivery] = await c + .get("db") + .insert(core_notification_deliveries) + .values({ + availableAt: now, + channel: "email", + createdAt: now, + idempotencyKey: `test:${adminUserId}:${now.getTime()}`, + mode: "test", + updatedAt: now, + userId: adminUserId, + }) + .returning({ id: core_notification_deliveries.id }); + + await dispatchEmailDrain(c); + + return { deliveryId: delivery.id }; +}; + +const MISMATCH_SCAN_LIMIT = 500; + +const findNotificationCountMismatches = async ( + c: NotificationsContext, + userId?: number, +) => { + const actual = c + .get("db") + .select({ + count: sql`count(*)::integer`.as("count"), + userId: core_notifications.userId, + }) + .from(core_notifications) + .where(UNREAD_SQL) + .groupBy(core_notifications.userId) + .as("actual"); + + return await c + .get("db") + .select({ + actual: sql`coalesce(${actual.count}, 0)`.mapWith(Number), + stored: core_notification_user_state.unreadCount, + userId: core_notification_user_state.userId, + }) + .from(core_notification_user_state) + .leftJoin(actual, eq(actual.userId, core_notification_user_state.userId)) + .where( + and( + sql`${core_notification_user_state.unreadCount} <> coalesce(${actual.count}, 0)`, + userId === undefined + ? undefined + : eq(core_notification_user_state.userId, userId), + ), + ) + .limit(MISMATCH_SCAN_LIMIT); +}; + +export const reconcileNotifications = async ( + c: NotificationsContext, + { dryRun, userId }: { dryRun: boolean; userId?: number }, +) => { + const mismatches = await findNotificationCountMismatches(c, userId); + + if (dryRun || mismatches.length === 0) { + return { corrected: 0, mismatched: mismatches.length }; + } + + const { corrected } = await reconcileNotificationCounts( + c, + mismatches.map(row => row.userId), + ); + + return { corrected, mismatched: mismatches.length }; +}; diff --git a/packages/vitnode/src/api/models/notifications/danger.integration.test.ts b/packages/vitnode/src/api/models/notifications/danger.integration.test.ts new file mode 100644 index 000000000..033efa81a --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/danger.integration.test.ts @@ -0,0 +1,306 @@ +// @vitest-environment node +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, expect, it } from "vitest"; +import { z } from "zod"; + +import { buildNotificationType } from "@/api/lib/notifications/registry"; +import { + core_notification_deliveries, + core_notification_events, + core_notification_receipts, + core_notification_user_state, + core_notifications, +} from "@/database/notifications"; +import { + createNotificationsHarness, + type NotificationsHarness, +} from "@/tests/notifications"; +import { describePostgres } from "@/tests/postgres"; + +import { updateNotificationTypePolicy } from "./admin"; +import { + cancelQueuedNotificationEmails, + deleteAllNotifications, + markEverythingRead, + pauseNotifications, + resetMemberNotificationPreferences, + resumeNotifications, +} from "./danger"; +import { processNotificationEvent } from "./fanout"; +import { getNotificationState } from "./inbox"; +import { + getNotificationPreferences, + updateNotificationPreferences, +} from "./preferences"; +import { loadNotificationSettings } from "./shared"; +import { getNotificationStats } from "./stats"; + +const alertType = buildNotificationType({ + id: "test.alert", + version: 1, + schema: z.object({ text: z.string() }), + category: "account", + label: "test.alert.label", + defaults: { email: "immediate", inApp: true }, + email: true, + present: ({ data }) => ({ title: data.text, target: "/alerts" }), +}); + +const newsType = buildNotificationType({ + id: "test.news", + version: 1, + schema: z.object({ text: z.string() }), + category: "content", + label: "test.news.label", + defaults: { email: "daily", inApp: true }, + email: true, + present: ({ data }) => ({ title: data.text, target: "/news" }), +}); + +describePostgres("notification admin actions", () => { + let h: NotificationsHarness; + let seq = 0; + + beforeAll(async () => { + h = await createNotificationsHarness({ types: [alertType, newsType] }); + }); + + afterAll(async () => { + await h?.database.drop(); + }); + + const publish = async (type: typeof alertType, userId: number) => { + seq += 1; + await h.c.get("notifications").publish({ + actorId: null, + data: { text: `n${seq}` }, + idempotencyKey: `admin-${seq}`, + recipients: [userId], + type, + }); + }; + + const itemsOf = async (userId: number) => + await h.c + .get("db") + .select() + .from(core_notifications) + .where(eq(core_notifications.userId, userId)); + + const settle = async () => await h.drainQueue(new Date(Date.now() + 5000)); + + const deliveriesOf = async (userId: number) => + await h.c + .get("db") + .select() + .from(core_notification_deliveries) + .where(eq(core_notification_deliveries.userId, userId)); + + it("merges type policy changes instead of replacing the stored policy", async () => { + await updateNotificationTypePolicy(h.c, "test.news", { inApp: false }); + await updateNotificationTypePolicy(h.c, "test.news", { allowPush: false }); + + const { policies } = await loadNotificationSettings(h.c.get("db")); + expect(policies.get("test.news")).toEqual({ + allowPush: false, + inApp: false, + }); + + await updateNotificationTypePolicy(h.c, "test.news", { + allowPush: true, + inApp: true, + }); + }); + + it("refuses member changes to a type locked by the installation", async () => { + const [user] = await h.createUsers(1); + await updateNotificationTypePolicy(h.c, "test.news", { + memberCanEdit: false, + }); + + await expect( + updateNotificationPreferences(h.c, user, { + types: { "test.news": { inApp: false } }, + }), + ).rejects.toMatchObject({ status: 400 }); + + const view = await getNotificationPreferences(h.c, { userId: user }); + expect(view.types.find(type => type.id === "test.news")?.locked).toBe(true); + + await updateNotificationTypePolicy(h.c, "test.news", { + memberCanEdit: true, + }); + }); + + it("saves a member's push choice and refuses push the installation turned off", async () => { + const [user] = await h.createUsers(1); + await updateNotificationPreferences(h.c, user, { + types: { "test.news": { push: false } }, + }); + + const view = await getNotificationPreferences(h.c, { userId: user }); + expect(view.types.find(type => type.id === "test.news")).toMatchObject({ + pushAvailable: true, + value: { push: false }, + }); + + await updateNotificationTypePolicy(h.c, "test.news", { allowPush: false }); + await expect( + updateNotificationPreferences(h.c, user, { + types: { "test.news": { push: true } }, + }), + ).rejects.toMatchObject({ status: 400 }); + await updateNotificationTypePolicy(h.c, "test.news", { allowPush: true }); + }); + + it("shows a member the installation's email default next to their own choice", async () => { + const [user] = await h.createUsers(1); + await updateNotificationPreferences(h.c, user, { + types: { "test.news": { email: "none" } }, + }); + await updateNotificationTypePolicy(h.c, "test.news", { email: "weekly" }); + + const view = await getNotificationPreferences(h.c, { userId: user }); + expect(view.types.find(type => type.id === "test.news")).toMatchObject({ + defaultEmail: "weekly", + value: { email: "none" }, + }); + + await updateNotificationTypePolicy(h.c, "test.news", { email: "daily" }); + }); + + it("holds every event while paused and delivers them on resume", async () => { + const [user] = await h.createUsers(1); + await pauseNotifications(h.c); + await publish(alertType, user); + await settle(); + + expect(await itemsOf(user)).toHaveLength(0); + expect(await deliveriesOf(user)).toHaveLength(0); + + const { requeuedEvents } = await resumeNotifications(h.c); + await settle(); + + expect(requeuedEvents).toBeGreaterThanOrEqual(1); + expect(await itemsOf(user)).toHaveLength(1); + expect((await deliveriesOf(user)).map(row => row.status)).toEqual(["sent"]); + expect(h.emitted.map(event => event.name)).toEqual( + expect.arrayContaining(["notifications.paused", "notifications.resumed"]), + ); + }); + + it("cancels every queued email and pending digest, and keeps the inbox", async () => { + const [user] = await h.createUsers(1); + seq += 1; + const immediate = await h.c.get("notifications").publish({ + actorId: null, + data: { text: `n${seq}` }, + idempotencyKey: `admin-${seq}`, + recipients: [user], + type: alertType, + }); + seq += 1; + const digest = await h.c.get("notifications").publish({ + actorId: null, + data: { text: `n${seq}` }, + idempotencyKey: `admin-${seq}`, + recipients: [user], + type: newsType, + }); + await processNotificationEvent(h.c, immediate.eventId); + await processNotificationEvent(h.c, digest.eventId); + + const [queued] = await deliveriesOf(user); + expect(queued?.status).toBe("pending"); + + const { cancelled } = await cancelQueuedNotificationEmails(h.c); + expect(cancelled).toBeGreaterThanOrEqual(1); + + expect((await deliveriesOf(user))[0]).toMatchObject({ + skipReason: "cancelled", + status: "skipped", + }); + const receipts = await h.c + .get("db") + .select() + .from(core_notification_receipts) + .where(eq(core_notification_receipts.userId, user)); + expect(receipts.every(row => !row.emailPending)).toBe(true); + expect(await itemsOf(user)).toHaveLength(2); + }); + + it("marks everything read for everyone and zeroes every badge", async () => { + const [first, second] = await h.createUsers(2); + await publish(newsType, first); + await publish(newsType, second); + await publish(newsType, second); + await settle(); + + const result = await markEverythingRead(h.c); + + expect(result.items).toBeGreaterThanOrEqual(3); + expect((await getNotificationState(h.c.get("db"), first)).unread).toBe(0); + expect((await getNotificationState(h.c.get("db"), second)).unread).toBe(0); + expect( + h.realtime.filter(record => record.userId === second).at(-1)?.data, + ).toMatchObject({ reason: "read_all", unread: 0 }); + }); + + it("reports activity per bucket in the viewer's time zone", async () => { + const [first, second] = await h.createUsers(2); + const before = await getNotificationStats(h.c, { + range: "24h", + timeZone: "Europe/Warsaw", + }); + await publish(alertType, first); + await publish(alertType, second); + await settle(); + + const stats = await getNotificationStats(h.c, { + range: "24h", + timeZone: "Europe/Warsaw", + }); + + expect(stats.points).toHaveLength(24); + expect(stats.totals.events).toBe(before.totals.events + 2); + expect(stats.totals.sent).toBe(before.totals.sent + 2); + }); + + it("resets every member's own choices to the installation defaults", async () => { + const [user] = await h.createUsers(1); + await updateNotificationPreferences(h.c, user, { + types: { "test.news": { inApp: false } }, + }); + + const { members } = await resetMemberNotificationPreferences(h.c); + + const [state] = await h.c + .get("db") + .select() + .from(core_notification_user_state) + .where(eq(core_notification_user_state.userId, user)); + expect(members).toBeGreaterThanOrEqual(1); + expect(state).toMatchObject({ + preferences: {}, + }); + }); + + it("deletes every notification and the events nothing points at any more", async () => { + const [user] = await h.createUsers(1); + await publish(newsType, user); + await settle(); + + const result = await deleteAllNotifications(h.c); + + expect(result.items).toBeGreaterThanOrEqual(1); + expect(await h.c.get("db").select().from(core_notifications)).toHaveLength( + 0, + ); + expect( + (await h.c.get("db").select().from(core_notification_events)).every( + event => event.status === "pending" || event.status === "processing", + ), + ).toBe(true); + expect((await getNotificationState(h.c.get("db"), user)).unread).toBe(0); + }); +}); diff --git a/packages/vitnode/src/api/models/notifications/danger.ts b/packages/vitnode/src/api/models/notifications/danger.ts new file mode 100644 index 000000000..8007b0ede --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/danger.ts @@ -0,0 +1,187 @@ +import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; + +import { + core_notification_deliveries, + core_notification_events, + core_notification_receipts, + core_notification_user_state, + core_notifications, +} from "@/database/notifications"; + +import type { NotificationsContext } from "./shared"; + +import { upsertNotificationSetting } from "./admin"; +import { dispatchEmailDrain } from "./email-queue"; +import { applyUnreadDeltas, lockUserStates } from "./fanout"; +import { removeNotificationItems, UNREAD_SQL } from "./inbox"; +import { deleteUnreferencedEvents } from "./maintenance"; +import { + GLOBAL_SETTINGS_KEY, + loadNotificationSettings, + NOTIFICATIONS_PLUGIN_ID, + QUEUE_NOTIFICATIONS_FANOUT, + sendNotificationStates, +} from "./shared"; + +const USERS_PER_BATCH = 200; + +const setPaused = async (c: NotificationsContext, paused: boolean) => { + const { global } = await loadNotificationSettings(c.get("db")); + await upsertNotificationSetting(c, GLOBAL_SETTINGS_KEY, { + ...global, + paused, + }); +}; + +export const pauseNotifications = async (c: NotificationsContext) => { + await setPaused(c, true); + await c.get("events").emit("notifications.paused", {}); +}; + +export const resumeNotifications = async ( + c: NotificationsContext, +): Promise<{ requeuedEvents: number }> => { + await setPaused(c, false); + + const waiting = await c + .get("db") + .select({ id: core_notification_events.id }) + .from(core_notification_events) + .where(inArray(core_notification_events.status, ["pending", "processing"])); + + await Promise.all( + waiting.map(async ({ id }) => + c.get("queue").dispatch({ + name: QUEUE_NOTIFICATIONS_FANOUT, + payload: { eventId: id }, + pluginId: NOTIFICATIONS_PLUGIN_ID, + priority: 10, + }), + ), + ); + await dispatchEmailDrain(c); + await c + .get("events") + .emit("notifications.resumed", { requeuedEvents: waiting.length }); + + return { requeuedEvents: waiting.length }; +}; + +export const cancelQueuedNotificationEmails = async ( + c: NotificationsContext, +): Promise<{ cancelled: number }> => { + const cancelled = await c.get("db").transaction(async tx => { + const rows = await tx + .update(core_notification_deliveries) + .set({ + skipReason: "cancelled", + status: "skipped", + updatedAt: new Date(), + }) + .where(eq(core_notification_deliveries.status, "pending")) + .returning({ id: core_notification_deliveries.id }); + + await tx + .update(core_notification_receipts) + .set({ emailPending: false }) + .where( + and( + eq(core_notification_receipts.emailPending, true), + or( + isNull(core_notification_receipts.emailDeliveryId), + inArray( + core_notification_receipts.emailDeliveryId, + rows.map(row => row.id), + ), + ), + ), + ); + + return rows.length; + }); + + await c + .get("events") + .emit("notifications.emails_cancelled", { count: cancelled }); + + return { cancelled }; +}; + +export const markEverythingRead = async ( + c: NotificationsContext, +): Promise<{ items: number; members: number }> => { + let items = 0; + let members = 0; + + for (;;) { + const targets = await c + .get("db") + .selectDistinct({ userId: core_notifications.userId }) + .from(core_notifications) + .where(UNREAD_SQL) + .limit(USERS_PER_BATCH); + if (targets.length === 0) break; + + const userIds = targets.map(row => row.userId); + const batch = await c.get("db").transaction(async tx => { + await lockUserStates(tx, userIds); + const marked = await tx + .update(core_notifications) + .set({ + readAt: new Date(), + readSeq: sql`${core_notifications.activitySeq}`, + }) + .where(and(UNREAD_SQL, inArray(core_notifications.userId, userIds))) + .returning({ userId: core_notifications.userId }); + + const deltas = new Map(); + for (const row of marked) + deltas.set(row.userId, (deltas.get(row.userId) ?? 0) - 1); + + return { + count: marked.length, + updates: await applyUnreadDeltas(tx, deltas, "read_all"), + }; + }); + + sendNotificationStates(c, batch.updates); + items += batch.count; + members += batch.updates.length; + if (batch.count === 0) break; + } + + await c.get("events").emit("notifications.read_all", { items, members }); + + return { items, members }; +}; + +export const deleteAllNotifications = async ( + c: NotificationsContext, +): Promise<{ events: number; items: number }> => { + const items = await removeNotificationItems(c, { where: sql`true` }); + const events = await deleteUnreferencedEvents(c.get("db")); + + await c.get("events").emit("notifications.deleted_all", { items }); + + return { events, items }; +}; + +export const resetMemberNotificationPreferences = async ( + c: NotificationsContext, +): Promise<{ members: number }> => { + const rows = await c + .get("db") + .update(core_notification_user_state) + .set({ + preferences: sql`'{}'::jsonb`, + updatedAt: new Date(), + }) + .where(sql`${core_notification_user_state.preferences} <> '{}'::jsonb`) + .returning({ userId: core_notification_user_state.userId }); + + await c + .get("events") + .emit("notifications.preferences_reset", { members: rows.length }); + + return { members: rows.length }; +}; diff --git a/packages/vitnode/src/api/models/notifications/email-queue.ts b/packages/vitnode/src/api/models/notifications/email-queue.ts new file mode 100644 index 000000000..89a3bde25 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/email-queue.ts @@ -0,0 +1,45 @@ +import { and, asc, eq } from "drizzle-orm"; + +import { core_queue } from "@/database/queue"; + +import type { NotificationsContext, NotificationsDb } from "./shared"; + +import { NOTIFICATIONS_PLUGIN_ID, QUEUE_NOTIFICATIONS_EMAIL } from "./shared"; + +export const dispatchEmailDrain = async ( + c: NotificationsContext, + tx?: NotificationsDb, + availableAt?: Date, +): Promise => { + const db = tx ?? c.get("db"); + const [queued] = await db + .select({ availableAt: core_queue.availableAt, id: core_queue.id }) + .from(core_queue) + .where( + and( + eq(core_queue.pluginId, NOTIFICATIONS_PLUGIN_ID), + eq(core_queue.name, QUEUE_NOTIFICATIONS_EMAIL), + eq(core_queue.status, "pending"), + ), + ) + .orderBy(asc(core_queue.availableAt)) + .limit(1); + if (queued) { + const wanted = availableAt ?? new Date(); + if (queued.availableAt > wanted) { + await db + .update(core_queue) + .set({ availableAt: wanted }) + .where(eq(core_queue.id, queued.id)); + } + + return; + } + + await c.get("queue").dispatch({ + availableAt, + name: QUEUE_NOTIFICATIONS_EMAIL, + pluginId: NOTIFICATIONS_PLUGIN_ID, + tx: db, + }); +}; diff --git a/packages/vitnode/src/api/models/notifications/email.integration.test.ts b/packages/vitnode/src/api/models/notifications/email.integration.test.ts new file mode 100644 index 000000000..c0a7478c3 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/email.integration.test.ts @@ -0,0 +1,300 @@ +// @vitest-environment node +import { and, eq, inArray } from "drizzle-orm"; +import { afterAll, beforeAll, expect, it } from "vitest"; +import { z } from "zod"; + +import { buildNotificationType } from "@/api/lib/notifications/registry"; +import { + core_notification_deliveries, + core_notification_receipts, + core_notifications, +} from "@/database/notifications"; +import { core_users } from "@/database/users"; +import { + createNotificationsHarness, + type NotificationsHarness, +} from "@/tests/notifications"; +import { describePostgres } from "@/tests/postgres"; + +import { drainNotificationEmails, planNotificationDigests } from "./email"; +import { markNotificationRead } from "./inbox"; +import { updateNotificationPreferences } from "./preferences"; + +const alertType = buildNotificationType({ + id: "test.alert", + version: 1, + schema: z.object({ text: z.string() }), + category: "account", + label: "test.alert.label", + defaults: { email: "immediate", inApp: true }, + email: true, + present: ({ data }) => ({ title: `Alert: ${data.text}`, target: "/alerts" }), +}); + +const newsType = buildNotificationType({ + id: "test.news", + version: 1, + schema: z.object({ headline: z.string() }), + category: "content", + label: "test.news.label", + defaults: { email: "daily", inApp: true }, + email: true, + present: ({ data }) => ({ title: data.headline, target: "/news" }), +}); + +describePostgres("notification email", () => { + let h: NotificationsHarness; + let seq = 0; + + beforeAll(async () => { + h = await createNotificationsHarness({ types: [alertType, newsType] }); + }); + + afterAll(async () => { + await h?.database.drop(); + }); + + const publish = async ( + type: typeof alertType | typeof newsType, + userId: number, + text: string, + ) => { + seq += 1; + const data = type === alertType ? { text } : { headline: text }; + const result = await h.c.get("notifications").publish({ + actorId: null, + data: data, + idempotencyKey: `email-${seq}`, + recipients: [userId], + type: type as never, + }); + + return result.eventId; + }; + + const deliveriesOf = async (userId: number) => + await h.c + .get("db") + .select() + .from(core_notification_deliveries) + .where(eq(core_notification_deliveries.userId, userId)); + + const sentTo = (userId: number) => + h.sentEmails.filter(email => email.to === `user${userId}@example.com`); + + it("sends one immediate email through the queue, with a stable idempotency key", async () => { + const [user] = await h.createUsers(1); + const eventId = await publish(alertType, user, "login from a new device"); + await h.drainQueue(); + await drainNotificationEmails(h.c); + + const emails = sentTo(user); + expect(emails).toHaveLength(1); + expect(emails[0]).toMatchObject({ + idempotencyKey: `vitnode-notification-immediate:${eventId}:${user}`, + subject: "Alert: login from a new device", + }); + expect(emails[0].entries[0]?.url).toMatch(/\/alerts$/); + + const [delivery] = await deliveriesOf(user); + expect(delivery).toMatchObject({ + attempts: 1, + providerMessageId: "message-1", + status: "sent", + }); + }); + + it("retries a provider failure with backoff and never sends twice", async () => { + const [user] = await h.createUsers(1); + await publish(alertType, user, "password changed"); + await h.drainQueue(); + + h.setEmailFailure( + new Error(`SMTP 451 rejected for user${user}@example.com token=abc123`), + ); + await drainNotificationEmails(h.c); + h.setEmailFailure(null); + + const [failed] = await deliveriesOf(user); + expect(failed.status).toBe("pending"); + expect(failed.attempts).toBe(1); + expect(failed.lastError).toBe( + "SMTP 451 rejected for [email] token=[redacted]", + ); + expect(failed.availableAt.getTime()).toBeGreaterThan(Date.now()); + expect(sentTo(user)).toHaveLength(0); + + await drainNotificationEmails(h.c); + expect(sentTo(user)).toHaveLength(0); + + const later = new Date(Date.now() + 60 * 60 * 1000); + await drainNotificationEmails(h.c, { now: later }); + await drainNotificationEmails(h.c, { now: later }); + + expect(sentTo(user)).toHaveLength(1); + const [sent] = await deliveriesOf(user); + expect(sent).toMatchObject({ attempts: 2, status: "sent" }); + }); + + it("re-checks preferences and read state before a delayed send", async () => { + const [optedOut, alreadyRead] = await h.createUsers(2); + await publish(alertType, optedOut, "a"); + await publish(alertType, alreadyRead, "b"); + await h.drainQueue(); + + await updateNotificationPreferences(h.c, optedOut, { + types: { "test.alert": { email: "none" } }, + }); + const [item] = await h.c + .get("db") + .select() + .from(core_notifications) + .where(eq(core_notifications.userId, alreadyRead)); + await markNotificationRead(h.c, { + notificationId: item.id, + userId: alreadyRead, + }); + + await drainNotificationEmails(h.c); + + expect(sentTo(optedOut)).toHaveLength(0); + expect(sentTo(alreadyRead)).toHaveLength(0); + expect((await deliveriesOf(optedOut))[0]?.status).toBe("skipped"); + expect((await deliveriesOf(alreadyRead))[0]?.status).toBe("skipped"); + }); + + it("builds daily digests per local period across a DST change, once", async () => { + const [user] = await h.createUsers(1); + await h.c + .get("db") + .update(core_users) + .set({ timeZone: "Europe/Warsaw" }) + .where(eq(core_users.id, user)); + + const inside = await publish(newsType, user, "Saturday news"); + const after = await publish(newsType, user, "Sunday news"); + const readOne = await publish(newsType, user, "Already seen"); + await h.drainQueue(); + + const setCreated = async (eventId: number, at: string) => + await h.c + .get("db") + .update(core_notification_receipts) + .set({ createdAt: new Date(at) }) + .where( + and( + eq(core_notification_receipts.eventId, eventId), + eq(core_notification_receipts.userId, user), + ), + ); + await setCreated(inside, "2026-03-28T12:00:00Z"); + await setCreated(readOne, "2026-03-28T13:00:00Z"); + await setCreated(after, "2026-03-29T06:30:00Z"); + + const [seen] = await h.c + .get("db") + .select({ notificationId: core_notification_receipts.notificationId }) + .from(core_notification_receipts) + .where(eq(core_notification_receipts.eventId, readOne)); + await markNotificationRead(h.c, { + notificationId: seen.notificationId ?? 0, + userId: user, + }); + + const warsawEightAmBeforeDstChange = new Date("2026-03-28T07:00:00Z"); + const warsawEightAmAfterDstChange = new Date("2026-03-29T06:00:00Z"); + const firstRun = new Date("2026-03-29T10:00:00Z"); + await planNotificationDigests(h.c, { now: firstRun }); + await planNotificationDigests(h.c, { now: firstRun }); + await drainNotificationEmails(h.c, { now: firstRun }); + + const digests = (await deliveriesOf(user)).filter( + row => row.mode === "daily", + ); + expect(digests).toHaveLength(1); + expect(digests[0]).toMatchObject({ + idempotencyKey: `daily:${user}:2026-03-29`, + itemCount: 1, + periodEnd: warsawEightAmAfterDstChange, + periodStart: warsawEightAmBeforeDstChange, + status: "sent", + }); + expect( + sentTo(user) + .at(-1) + ?.entries.map(entry => entry.title), + ).toEqual(["Saturday news"]); + + const secondRun = new Date("2026-03-30T07:00:00Z"); + await planNotificationDigests(h.c, { now: secondRun }); + await drainNotificationEmails(h.c, { now: secondRun }); + + expect( + sentTo(user) + .at(-1) + ?.entries.map(entry => entry.title), + ).toEqual(["Sunday news"]); + + const sent = sentTo(user).length; + await planNotificationDigests(h.c, { + now: new Date("2026-03-31T07:00:00Z"), + }); + await drainNotificationEmails(h.c, { + now: new Date("2026-03-31T07:00:00Z"), + }); + expect(sentTo(user)).toHaveLength(sent); + + const pending = await h.c + .get("db") + .select() + .from(core_notification_receipts) + .where( + and( + eq(core_notification_receipts.userId, user), + eq(core_notification_receipts.emailPending, true), + ), + ); + expect(pending).toHaveLength(0); + }); + + it("re-renders the same digest when its send is retried", async () => { + const [user] = await h.createUsers(1); + await publish(newsType, user, "One"); + await publish(newsType, user, "Two"); + await h.drainQueue(); + const now = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000); + + await planNotificationDigests(h.c, { now }); + h.setEmailFailure(new Error("provider down")); + await drainNotificationEmails(h.c, { now }); + h.setEmailFailure(null); + + await publish(newsType, user, "Three"); + await h.drainQueue(); + await drainNotificationEmails(h.c, { + now: new Date(now.getTime() + 60 * 60 * 1000), + }); + + const titles = sentTo(user).flatMap(email => + email.entries.map(entry => entry.title), + ); + expect(titles.sort()).toEqual(["One", "Two"]); + + const daily = (await deliveriesOf(user)).filter( + row => row.mode === "daily", + ); + expect(daily.map(row => row.status)).toEqual(["sent"]); + expect( + await h.c + .get("db") + .select() + .from(core_notification_receipts) + .where( + and( + eq(core_notification_receipts.userId, user), + inArray(core_notification_receipts.emailDeliveryId, [daily[0].id]), + ), + ), + ).toHaveLength(2); + }); +}); diff --git a/packages/vitnode/src/api/models/notifications/email.ts b/packages/vitnode/src/api/models/notifications/email.ts new file mode 100644 index 000000000..f4638ca95 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/email.ts @@ -0,0 +1,753 @@ +import { and, asc, eq, inArray, isNull, lt, lte, sql } from "drizzle-orm"; +import { createElement } from "react"; + +import type { NotificationTypePreference } from "@/database/notifications"; +import type { NotificationEmailMode } from "@/lib/notifications/types"; + +import { latestEndedDigestPeriod } from "@/api/lib/notifications/digest-period"; +import { + NOTIFICATION_DIGEST_SCHEDULE, + resolveNotificationChannels, +} from "@/api/lib/notifications/preferences"; +import { sanitizeDeliveryError } from "@/api/lib/notifications/safe"; +import { core_languages } from "@/database/languages"; +import { + core_notification_deliveries, + core_notification_events, + core_notification_receipts, + core_notification_user_state, + core_notifications, +} from "@/database/notifications"; +import { core_users } from "@/database/users"; +import NotificationEmailTemplate, { + type NotificationEmailEntry, +} from "@/emails/notification"; +import { getQueueBackoffDate } from "@/lib/api/get-queue-backoff-date"; +import { CONFIG } from "@/lib/config"; + +import type { NotificationEventRow } from "./render"; +import type { + NotificationsContext, + NotificationSettingsSnapshot, +} from "./shared"; + +import { dispatchEmailDrain } from "./email-queue"; +import { valuesList } from "./fanout"; +import { + checkNotificationAccess, + createTranslatorCache, + loadActors, + presentNotificationEmail, + resolveEvent, +} from "./render"; +import { + getNotificationWorkers, + isEmailConfigured, + loadNotificationSettings, +} from "./shared"; + +type DeliveryRow = typeof core_notification_deliveries.$inferSelect; +type DeliveryOutcome = + | { providerMessageId?: string; status: "sent" } + | { reason: string; status: "skipped" }; + +const MAX_DIGEST_ENTRIES = 25; +const STALE_SENDING_MINUTES = 15; +const DIGEST_USERS_PER_PAGE = 200; +const DIGEST_MAX_USERS_PER_RUN = 5_000; + +const absoluteUrl = (target: null | string): null | string => + target ? new URL(target, CONFIG.web).href : null; + +const preferencesUrl = () => + new URL("/settings/notifications", CONFIG.web).href; + +const providerIdempotencyKey = (delivery: DeliveryRow) => + `vitnode-notification-${delivery.idempotencyKey}`; + +const currentEmailMode = ({ + c, + event, + preferences, + settings, +}: { + c: NotificationsContext; + event: NotificationEventRow; + preferences: Record; + settings: NotificationSettingsSnapshot; +}): NotificationEmailMode => { + const resolved = resolveEvent(c, event); + if (!resolved) return "none"; + + return resolveNotificationChannels({ + definition: resolved.registered.definition, + emailConfigured: isEmailConfigured(c), + policy: settings.policies.get(event.type), + preference: preferences[event.type], + }).email; +}; + +interface ClaimedReceipt { + createdAt: Date; + event: NotificationEventRow; + eventId: number; + notificationId: null | number; + seq: null | number; +} + +const settleReceipts = async ( + c: NotificationsContext, + userId: number, + { dropped, replan }: { dropped: number[]; replan: number[] }, +) => { + const db = c.get("db"); + if (dropped.length > 0) { + await db + .update(core_notification_receipts) + .set({ emailPending: false }) + .where( + and( + eq(core_notification_receipts.userId, userId), + inArray(core_notification_receipts.eventId, dropped), + ), + ); + } + if (replan.length > 0) { + await db + .update(core_notification_receipts) + .set({ emailDeliveryId: null }) + .where( + and( + eq(core_notification_receipts.userId, userId), + inArray(core_notification_receipts.eventId, replan), + ), + ); + } +}; + +const loadEventIdsHandledByUser = async ( + c: NotificationsContext, + receipts: ClaimedReceipt[], +): Promise> => { + const ids = receipts + .map(receipt => receipt.notificationId) + .filter(id => id !== null); + if (ids.length === 0) return new Set(); + + const notifications = await c + .get("db") + .select({ + archivedAt: core_notifications.archivedAt, + id: core_notifications.id, + readSeq: core_notifications.readSeq, + }) + .from(core_notifications) + .where(inArray(core_notifications.id, ids)); + const notificationById = new Map( + notifications.map(notification => [notification.id, notification]), + ); + + return new Set( + receipts + .filter(receipt => { + if (receipt.notificationId === null || receipt.seq === null) { + return false; + } + const notification = notificationById.get(receipt.notificationId); + const removed = !notification; + + return ( + removed || + notification.archivedAt !== null || + notification.readSeq >= receipt.seq + ); + }) + .map(receipt => receipt.eventId), + ); +}; + +const deliverNotificationEmail = async ( + c: NotificationsContext, + delivery: DeliveryRow, + settings: NotificationSettingsSnapshot, + translator: ReturnType, +): Promise => { + const db = c.get("db"); + const [user] = await db + .select({ + email: core_users.email, + id: core_users.id, + language: core_users.language, + name: core_users.name, + preferences: core_notification_user_state.preferences, + }) + .from(core_users) + .leftJoin( + core_notification_user_state, + eq(core_notification_user_state.userId, core_users.id), + ) + .where(eq(core_users.id, delivery.userId)) + .limit(1); + if (!user) return { reason: "user_missing", status: "skipped" }; + if (!isEmailConfigured(c)) { + return { reason: "email_disabled", status: "skipped" }; + } + + const { locale, t } = await translator(user.language); + const emailModel = c.get("email"); + const commonProps = { + actionLabel: t("core.notifications.email.action"), + preferencesLabel: t("core.notifications.email.preferences"), + preferencesUrl: preferencesUrl(), + }; + + if (delivery.mode === "test") { + const built = await emailModel.build({ + content: props => + createElement(NotificationEmailTemplate, { + ...props, + ...commonProps, + entries: [ + { + body: t("core.notifications.email.test_body"), + title: t("core.notifications.email.test_title"), + url: absoluteUrl("/notifications"), + }, + ], + heading: t("core.notifications.email.test_title"), + }), + locale, + subject: t("core.notifications.email.test_subject"), + to: user.email, + }); + const sent = await emailModel.deliver(built, { + idempotencyKey: providerIdempotencyKey(delivery), + }); + + return { providerMessageId: sent.id, status: "sent" }; + } + + const claimed: ClaimedReceipt[] = ( + await db + .select({ + createdAt: core_notification_receipts.createdAt, + event: core_notification_events, + eventId: core_notification_receipts.eventId, + notificationId: core_notification_receipts.notificationId, + seq: core_notification_receipts.seq, + }) + .from(core_notification_receipts) + .innerJoin( + core_notification_events, + eq(core_notification_events.id, core_notification_receipts.eventId), + ) + .where( + and( + eq(core_notification_receipts.userId, user.id), + eq(core_notification_receipts.emailDeliveryId, delivery.id), + eq(core_notification_receipts.emailPending, true), + ), + ) + .orderBy(asc(core_notification_receipts.createdAt)) + ).reverse(); + + if (claimed.length === 0) return { reason: "empty", status: "skipped" }; + + const handledByUser = await loadEventIdsHandledByUser(c, claimed); + const preferences = user.preferences ?? {}; + const dropped: number[] = []; + const replan: number[] = []; + const kept: { event: NotificationEventRow; receipt: ClaimedReceipt }[] = []; + + for (const receipt of claimed) { + const mode = currentEmailMode({ + c, + event: receipt.event, + preferences, + settings, + }); + + const modeChangedSinceQueued = mode !== delivery.mode; + if (modeChangedSinceQueued) { + (mode === "none" ? dropped : replan).push(receipt.eventId); + } else if (handledByUser.has(receipt.eventId)) { + dropped.push(receipt.eventId); + } else { + kept.push({ event: receipt.event, receipt }); + } + } + + const actors = await loadActors( + c, + kept.map(entry => entry.event.actorId).filter(id => id !== null), + ); + const entries: (NotificationEmailEntry & { subject: string })[] = []; + + for (const { event } of kept) { + const resolved = resolveEvent(c, event); + const allowed = resolved + ? await checkNotificationAccess({ + c, + data: resolved.data, + event, + registered: resolved.registered, + userIds: [user.id], + }) + : new Set(); + const actor = + event.actorId === null ? undefined : actors.get(event.actorId); + const presented = + resolved && allowed.has(user.id) + ? presentNotificationEmail(c, { + actors: { actorCount: actor ? 1 : 0, actors: actor ? [actor] : [] }, + data: resolved.data, + event, + eventCount: 1, + locale, + registered: resolved.registered, + t, + }) + : null; + + if (!presented) { + dropped.push(event.id); + continue; + } + + entries.push({ + body: presented.body, + subject: presented.subject, + title: presented.title, + url: absoluteUrl(presented.target), + }); + } + + await settleReceipts(c, user.id, { dropped, replan }); + if (entries.length === 0) return { reason: "empty", status: "skipped" }; + + const first = entries[0]; + const isDigest = delivery.mode !== "immediate"; + const shown = entries.slice(0, MAX_DIGEST_ENTRIES); + const subject = isDigest + ? t(`core.notifications.email.digest_subject_${delivery.mode}`, { + count: entries.length, + }) + : first.subject; + + const built = await emailModel.build({ + content: props => + createElement(NotificationEmailTemplate, { + ...props, + ...commonProps, + entries: shown, + heading: isDigest + ? t(`core.notifications.email.digest_heading_${delivery.mode}`) + : first.title, + intro: + isDigest && entries.length > shown.length + ? t("core.notifications.email.digest_more", { + count: entries.length - shown.length, + }) + : undefined, + }), + locale, + subject, + to: user.email, + }); + + const sent = await emailModel.deliver(built, { + idempotencyKey: providerIdempotencyKey(delivery), + }); + + await db + .update(core_notification_receipts) + .set({ emailPending: false }) + .where( + and( + eq(core_notification_receipts.userId, user.id), + eq(core_notification_receipts.emailDeliveryId, delivery.id), + ), + ); + await db + .update(core_notification_deliveries) + .set({ itemCount: entries.length }) + .where(eq(core_notification_deliveries.id, delivery.id)); + + return { providerMessageId: sent.id, status: "sent" }; +}; + +const runWithConcurrency = async ( + items: T[], + limit: number, + run: (item: T) => Promise, +) => { + let next = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const item = items[next]; + next += 1; + await run(item); + } + }), + ); +}; + +export const drainNotificationEmails = async ( + c: NotificationsContext, + { now = new Date() }: { now?: Date } = {}, +): Promise<{ + claimed: number; + failed: number; + sent: number; + skipped: number; +}> => { + const db = c.get("db"); + const settings = await loadNotificationSettings(db); + if (settings.global.paused) { + return { claimed: 0, failed: 0, sent: 0, skipped: 0 }; + } + + const claimed = await db.transaction(async tx => { + const due = await tx + .select({ id: core_notification_deliveries.id }) + .from(core_notification_deliveries) + .where( + and( + eq(core_notification_deliveries.status, "pending"), + lte(core_notification_deliveries.availableAt, now), + ), + ) + .orderBy( + asc(core_notification_deliveries.availableAt), + asc(core_notification_deliveries.id), + ) + .limit(getNotificationWorkers(c).emailBatchSize) + .for("update", { skipLocked: true }); + if (due.length === 0) return []; + + return await tx + .update(core_notification_deliveries) + .set({ + attempts: sql`${core_notification_deliveries.attempts} + 1`, + status: "sending", + updatedAt: now, + }) + .where( + inArray( + core_notification_deliveries.id, + due.map(row => row.id), + ), + ) + .returning(); + }); + + const stats = { claimed: claimed.length, failed: 0, sent: 0, skipped: 0 }; + const translator = createTranslatorCache(c); + + await runWithConcurrency( + claimed, + getNotificationWorkers(c).emailConcurrency, + async delivery => { + let patch: Partial; + try { + const outcome = await deliverNotificationEmail( + c, + delivery, + settings, + translator, + ); + patch = + outcome.status === "sent" + ? { + lastError: null, + providerMessageId: outcome.providerMessageId ?? null, + sentAt: new Date(), + status: "sent", + } + : { skipReason: outcome.reason, status: "skipped" }; + stats[outcome.status === "sent" ? "sent" : "skipped"] += 1; + } catch (error) { + const lastError = sanitizeDeliveryError(error); + const exhausted = delivery.attempts >= delivery.maxAttempts; + patch = exhausted + ? { lastError, status: "failed" } + : { + availableAt: getQueueBackoffDate(delivery.attempts), + lastError, + status: "pending", + }; + if (exhausted) stats.failed += 1; + await c + .get("log") + .warn( + `[Notifications] Email delivery ${delivery.id} failed (attempt ${delivery.attempts}/${delivery.maxAttempts}): ${lastError}`, + ); + } + + await db + .update(core_notification_deliveries) + .set({ ...patch, updatedAt: new Date() }) + .where( + and( + eq(core_notification_deliveries.id, delivery.id), + eq(core_notification_deliveries.status, "sending"), + ), + ); + }, + ); + + const [next] = await db + .select({ availableAt: core_notification_deliveries.availableAt }) + .from(core_notification_deliveries) + .where(eq(core_notification_deliveries.status, "pending")) + .orderBy(asc(core_notification_deliveries.availableAt)) + .limit(1); + if (next) { + await dispatchEmailDrain( + c, + undefined, + next.availableAt > now ? next.availableAt : undefined, + ); + } + + return stats; +}; + +export const reclaimStaleDeliveries = async ( + c: NotificationsContext, + now = new Date(), +): Promise => { + const rows = await c + .get("db") + .update(core_notification_deliveries) + .set({ availableAt: now, status: "pending", updatedAt: now }) + .where( + and( + eq(core_notification_deliveries.status, "sending"), + lt( + core_notification_deliveries.updatedAt, + new Date(now.getTime() - STALE_SENDING_MINUTES * 60_000), + ), + ), + ) + .returning({ id: core_notification_deliveries.id }); + + return rows.length; +}; + +interface PlannedDelivery { + eventIds: number[]; + idempotencyKey: string; + mode: "daily" | "immediate" | "weekly"; + periodEnd?: Date; + periodStart?: Date; + userId: number; +} + +export const planNotificationDigests = async ( + c: NotificationsContext, + { now = new Date() }: { now?: Date } = {}, +): Promise<{ deliveries: number; users: number }> => { + const db = c.get("db"); + const settings = await loadNotificationSettings(db); + let cursor = 0; + let users = 0; + let created = 0; + + while (users < DIGEST_MAX_USERS_PER_RUN) { + const page = await db + .selectDistinct({ userId: core_notification_receipts.userId }) + .from(core_notification_receipts) + .where( + and( + eq(core_notification_receipts.emailPending, true), + isNull(core_notification_receipts.emailDeliveryId), + sql`${core_notification_receipts.userId} > ${cursor}`, + ), + ) + .orderBy(asc(core_notification_receipts.userId)) + .limit(DIGEST_USERS_PER_PAGE); + if (page.length === 0) break; + + const userIds = page.map(row => row.userId); + cursor = userIds.at(-1) ?? cursor; + users += userIds.length; + + const [profiles, receipts] = await Promise.all([ + db + .select({ + languageTimeZone: core_languages.timezone, + preferences: core_notification_user_state.preferences, + timeZone: core_users.timeZone, + userId: core_users.id, + }) + .from(core_users) + .leftJoin( + core_notification_user_state, + eq(core_notification_user_state.userId, core_users.id), + ) + .leftJoin(core_languages, eq(core_languages.code, core_users.language)) + .where(inArray(core_users.id, userIds)), + db + .select({ + createdAt: core_notification_receipts.createdAt, + event: core_notification_events, + userId: core_notification_receipts.userId, + }) + .from(core_notification_receipts) + .innerJoin( + core_notification_events, + eq(core_notification_events.id, core_notification_receipts.eventId), + ) + .where( + and( + inArray(core_notification_receipts.userId, userIds), + eq(core_notification_receipts.emailPending, true), + isNull(core_notification_receipts.emailDeliveryId), + ), + ), + ]); + + const profileOf = new Map(profiles.map(row => [row.userId, row])); + const plans = new Map(); + const dropped: { eventId: number; userId: number }[] = []; + + for (const receipt of receipts) { + const profile = profileOf.get(receipt.userId); + const mode = profile + ? currentEmailMode({ + c, + event: receipt.event, + preferences: profile.preferences ?? {}, + settings, + }) + : "none"; + + if (mode === "none" || !profile) { + dropped.push({ eventId: receipt.event.id, userId: receipt.userId }); + continue; + } + + if (mode === "immediate") { + const key = `immediate:${receipt.event.id}:${receipt.userId}`; + plans.set(key, { + eventIds: [receipt.event.id], + idempotencyKey: key, + mode, + userId: receipt.userId, + }); + continue; + } + + const period = latestEndedDigestPeriod({ + hour: NOTIFICATION_DIGEST_SCHEDULE.hour, + mode, + now, + timeZone: profile.timeZone ?? profile.languageTimeZone ?? "UTC", + weekday: NOTIFICATION_DIGEST_SCHEDULE.weekday, + }); + const belongsToRunningPeriod = receipt.createdAt >= period.end; + if (belongsToRunningPeriod) continue; + + const key = `${mode}:${receipt.userId}:${period.key}`; + const plan = plans.get(key) ?? { + eventIds: [], + idempotencyKey: key, + mode, + periodEnd: period.end, + periodStart: period.start, + userId: receipt.userId, + }; + plan.eventIds.push(receipt.event.id); + plans.set(key, plan); + } + + created += await db.transaction(async tx => { + if (dropped.length > 0) { + await tx.execute(sql` + UPDATE ${core_notification_receipts} AS r SET "emailPending" = false + FROM (VALUES ${valuesList( + dropped.map(row => [ + sql`${row.eventId}::bigint`, + sql`${row.userId}::integer`, + ]), + )}) AS v(eid, uid) + WHERE r."eventId" = v.eid AND r."userId" = v.uid + `); + } + + const planned = [...plans.values()]; + if (planned.length === 0) return 0; + + const inserted = await tx + .insert(core_notification_deliveries) + .values( + planned.map(plan => ({ + availableAt: now, + channel: "email" as const, + createdAt: now, + eventId: plan.mode === "immediate" ? plan.eventIds[0] : null, + idempotencyKey: plan.idempotencyKey, + mode: plan.mode, + periodEnd: plan.periodEnd ?? null, + periodStart: plan.periodStart ?? null, + updatedAt: now, + userId: plan.userId, + })), + ) + .onConflictDoNothing() + .returning({ + id: core_notification_deliveries.id, + idempotencyKey: core_notification_deliveries.idempotencyKey, + }); + + const insertedIdByKey = new Map( + inserted.map(row => [row.idempotencyKey, row.id]), + ); + const claims = planned.flatMap(plan => { + const deliveryId = insertedIdByKey.get(plan.idempotencyKey); + if (deliveryId === undefined) return []; + + return plan.eventIds.map(eventId => [ + sql`${eventId}::bigint`, + sql`${plan.userId}::integer`, + sql`${deliveryId}::bigint`, + ]); + }); + + if (claims.length > 0) { + await tx.execute(sql` + UPDATE ${core_notification_receipts} AS r SET "emailDeliveryId" = v.did + FROM (VALUES ${valuesList(claims)}) AS v(eid, uid, did) + WHERE r."eventId" = v.eid AND r."userId" = v.uid + AND r."emailDeliveryId" IS NULL + `); + } + + const immediateAlreadyDelivered = planned.filter( + plan => + plan.mode === "immediate" && + !insertedIdByKey.has(plan.idempotencyKey), + ); + if (immediateAlreadyDelivered.length > 0) { + await tx.execute(sql` + UPDATE ${core_notification_receipts} AS r SET "emailPending" = false + FROM (VALUES ${valuesList( + immediateAlreadyDelivered.map(plan => [ + sql`${plan.eventIds[0]}::bigint`, + sql`${plan.userId}::integer`, + ]), + )}) AS v(eid, uid) + WHERE r."eventId" = v.eid AND r."userId" = v.uid + `); + } + + if (inserted.length > 0) await dispatchEmailDrain(c, tx); + + return inserted.length; + }); + } + + return { deliveries: created, users }; +}; diff --git a/packages/vitnode/src/api/models/notifications/fanout.integration.test.ts b/packages/vitnode/src/api/models/notifications/fanout.integration.test.ts new file mode 100644 index 000000000..35dda5769 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/fanout.integration.test.ts @@ -0,0 +1,250 @@ +// @vitest-environment node +import { and, eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, expect, it } from "vitest"; +import { z } from "zod"; + +import { buildNotificationType } from "@/api/lib/notifications/registry"; +import { + core_notification_events, + core_notification_receipts, + core_notification_user_state, + core_notifications, +} from "@/database/notifications"; +import { core_queue } from "@/database/queue"; +import { + createNotificationsHarness, + type NotificationsHarness, +} from "@/tests/notifications"; +import { describePostgres } from "@/tests/postgres"; + +import { processNotificationEvent } from "./fanout"; +import { getNotificationState } from "./inbox"; + +const commentType = buildNotificationType({ + id: "test.comment", + version: 1, + schema: z.object({ postId: z.number(), title: z.string() }), + category: "social", + label: "test.comment.label", + subjectType: "test.post", + defaults: { email: "none", inApp: true }, + grouping: { windowMinutes: 60 }, + present: ({ actorCount, actors, data }) => ({ + title: `${actors[0]?.name ?? "Someone"} and ${actorCount - 1} others commented on ${data.title}`, + target: `/posts/${data.postId}`, + }), +}); + +const secretType = buildNotificationType({ + id: "test.secret", + version: 1, + schema: z.object({ allowed: z.array(z.number()) }), + category: "content", + label: "test.secret.label", + defaults: { email: "none", inApp: true }, + access: ({ data, userIds }) => + userIds.filter(id => data.allowed.includes(id)), + present: () => ({ title: "Secret", target: "https://evil.example/steal" }), +}); + +describePostgres("notification fan-out", () => { + let h: NotificationsHarness; + + beforeAll(async () => { + h = await createNotificationsHarness({ types: [commentType, secretType] }); + }); + + afterAll(async () => { + await h?.database.drop(); + }); + + const unreadOf = async (userId: number) => + (await getNotificationState(h.c.get("db"), userId)).unread; + + it("rejects unregistered types and invalid payloads", async () => { + await expect( + h.c.get("notifications").publish({ + data: {}, + idempotencyKey: "x", + recipients: [1], + type: "test.missing", + }), + ).rejects.toThrow(/not registered/); + + await expect( + h.c.get("notifications").publish({ + data: { postId: "nope" as unknown as number, title: "t" }, + idempotencyKey: "x", + recipients: [1], + type: commentType, + }), + ).rejects.toThrow(/Invalid data/); + }); + + it("rolls back with the producer's transaction", async () => { + const [user] = await h.createUsers(1); + + await expect( + h.c.get("db").transaction(async tx => { + await h.c.get("notifications").publish({ + data: { postId: 1, title: "Rolled back" }, + idempotencyKey: "rollback-1", + recipients: [user], + subject: { id: 1, type: "test.post" }, + tx, + type: commentType, + }); + throw new Error("producer failed"); + }), + ).rejects.toThrow("producer failed"); + + const events = await h.c + .get("db") + .select() + .from(core_notification_events) + .where(eq(core_notification_events.idempotencyKey, "rollback-1")); + const tasks = await h.c + .get("db") + .select() + .from(core_queue) + .where(sql`${core_queue.payload}->>'eventId' IS NOT NULL`); + + expect(events).toHaveLength(0); + expect(tasks.filter(task => task.status === "pending")).toHaveLength(0); + }); + + it("commits durably with the producer and delivers once, skipping the actor", async () => { + const [actor, a, b] = await h.createUsers(3); + + const first = await h.c.get("db").transaction( + async tx => + await h.c.get("notifications").publish({ + actorId: actor, + data: { postId: 7, title: "Seven" }, + idempotencyKey: "comment-71", + recipients: [a, b, b, actor], + subject: { id: 7, type: "test.post" }, + tx, + type: commentType, + }), + ); + const again = await h.c.get("notifications").publish({ + actorId: actor, + data: { postId: 7, title: "Seven" }, + idempotencyKey: "comment-71", + recipients: [a, b], + subject: { id: 7, type: "test.post" }, + type: commentType, + }); + + expect(again).toEqual({ duplicate: true, eventId: first.eventId }); + + await h.drainQueue(); + await processNotificationEvent(h.c, first.eventId); + + expect(await unreadOf(a)).toBe(1); + expect(await unreadOf(b)).toBe(1); + expect(await unreadOf(actor)).toBe(0); + + const receipts = await h.c + .get("db") + .select() + .from(core_notification_receipts) + .where(eq(core_notification_receipts.eventId, first.eventId)); + const byId = (x: number, y: number) => x - y; + expect(receipts.map(row => row.userId).sort(byId)).toEqual( + [a, b].sort(byId), + ); + }); + + it("replays a crashed batch without duplicating anything", async () => { + const [actor, a] = await h.createUsers(2); + const { eventId } = await h.c.get("notifications").publish({ + actorId: actor, + data: { postId: 8, title: "Eight" }, + idempotencyKey: "comment-81", + recipients: [a], + subject: { id: 8, type: "test.post" }, + type: commentType, + }); + await h.drainQueue(); + + await h.c + .get("db") + .update(core_notification_events) + .set({ recipientCursor: 0, status: "processing" }) + .where(eq(core_notification_events.id, eventId)); + await processNotificationEvent(h.c, eventId); + + const [item] = await h.c + .get("db") + .select() + .from(core_notifications) + .where(eq(core_notifications.userId, a)); + + expect(item?.eventCount).toBe(1); + expect(await unreadOf(a)).toBe(1); + }); + + it("groups events and re-opens a read group exactly once", async () => { + const [x, y, owner] = await h.createUsers(3); + const publish = async (actorId: number, key: string) => + await h.c.get("notifications").publish({ + actorId, + data: { postId: 9, title: "Nine" }, + idempotencyKey: key, + recipients: [owner], + subject: { id: 9, type: "test.post" }, + type: commentType, + }); + + await publish(x, "c-9-1"); + await publish(y, "c-9-2"); + await h.drainQueue(); + + const items = await h.c + .get("db") + .select() + .from(core_notifications) + .where(eq(core_notifications.userId, owner)); + expect(items).toHaveLength(1); + expect(items[0]?.eventCount).toBe(2); + expect(await unreadOf(owner)).toBe(1); + + await h.c + .get("db") + .update(core_notifications) + .set({ readSeq: sql`${core_notifications.activitySeq}` }) + .where(eq(core_notifications.userId, owner)); + await h.c + .get("db") + .update(core_notification_user_state) + .set({ unreadCount: 0 }) + .where(eq(core_notification_user_state.userId, owner)); + + await publish(x, "c-9-3"); + await h.drainQueue(); + + expect(await unreadOf(owner)).toBe(1); + }); + + it("checks access per batch and never sends unsafe targets", async () => { + const [allowed, denied] = await h.createUsers(2); + await h.c.get("notifications").publish({ + actorId: null, + data: { allowed: [allowed] }, + idempotencyKey: "secret-1", + recipients: [allowed, denied], + type: secretType, + }); + await h.drainQueue(); + + const rows = await h.c + .get("db") + .select({ userId: core_notifications.userId }) + .from(core_notifications) + .where(and(eq(core_notifications.type, "test.secret"))); + + expect(rows.map(row => row.userId)).toEqual([allowed]); + }); +}); diff --git a/packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts b/packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts new file mode 100644 index 000000000..c34895472 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment node +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, expect, it } from "vitest"; +import { z } from "zod"; + +import { buildNotificationType } from "@/api/lib/notifications/registry"; +import { + core_notification_deliveries, + core_notification_events, + core_notification_receipts, + core_notification_user_state, + core_notifications, +} from "@/database/notifications"; +import { + createNotificationsHarness, + type NotificationsHarness, +} from "@/tests/notifications"; +import { describePostgres } from "@/tests/postgres"; + +import { processNotificationEvent } from "./fanout"; + +const RECIPIENTS = 1_000; +const BATCH = 200; + +let accessCalls = 0; +const announcementType = buildNotificationType({ + id: "test.announcement", + version: 1, + schema: z.object({ title: z.string() }), + category: "content", + label: "test.announcement.label", + subjectType: "test.topic", + defaults: { email: "immediate", inApp: true }, + email: true, + access: ({ userIds }) => { + accessCalls += 1; + + return userIds; + }, + present: ({ data }) => ({ title: data.title }), +}); + +describePostgres("notification fan-out with 1,000 recipients", () => { + let h: NotificationsHarness; + let users: number[] = []; + + beforeAll(async () => { + h = await createNotificationsHarness({ + types: [announcementType], + workers: { fanoutBatchSize: BATCH }, + }); + users = await h.createUsers(RECIPIENTS + 1); + + await h.c + .get("db") + .insert(core_notification_user_state) + .values({ + preferences: { "test.announcement": { email: "none", inApp: false } }, + userId: users[700], + }); + }, 60_000); + + afterAll(async () => { + await h?.database.drop(); + }); + + it("delivers once per recipient in bounded batches, and survives repeated processing", async () => { + const actor = users[0]; + const started = performance.now(); + const queriesBefore = h.queryCount(); + + const { eventId } = await h.c.get("notifications").publish({ + actorId: actor, + data: { title: "Big news" }, + idempotencyKey: "announcement-1", + recipients: [actor, ...users.slice(1), ...users.slice(400, 600)], + subject: { id: 1, type: "test.topic" }, + type: announcementType, + }); + const publishMs = performance.now() - started; + + const racingWorker = h.forkContext(); + const fanoutStarted = performance.now(); + await Promise.all([ + processNotificationEvent(h.c, eventId), + processNotificationEvent(racingWorker, eventId), + ]); + const fanoutMs = performance.now() - fanoutStarted; + const fanoutQueries = h.queryCount() - queriesBefore; + + const expected = RECIPIENTS - 1; + const countRows = async ( + table: typeof core_notification_receipts | typeof core_notifications, + ) => + ( + await h.c + .get("db") + .select({ count: sql`count(*)::integer` }) + .from(table) + )[0]?.count; + + expect(await countRows(core_notifications)).toBe(expected); + expect(await countRows(core_notification_receipts)).toBe(expected); + + const [event] = await h.c + .get("db") + .select() + .from(core_notification_events) + .where(eq(core_notification_events.id, eventId)); + expect(event).toMatchObject({ + deliveredCount: expected, + status: "completed", + }); + + await h.c + .get("db") + .update(core_notification_events) + .set({ + recipientCursor: 0, + status: "processing", + }) + .where(eq(core_notification_events.id, eventId)); + await processNotificationEvent(h.c, eventId); + await processNotificationEvent(h.c, eventId); + + expect(await countRows(core_notifications)).toBe(expected); + const [{ total, wrong }] = await h.c + .get("db") + .select({ + total: sql`sum(${core_notification_user_state.unreadCount})::integer`, + wrong: sql`count(*) filter (where ${core_notification_user_state.unreadCount} <> 1)::integer`, + }) + .from(core_notification_user_state); + expect(total).toBe(expected); + expect(wrong).toBe(1); + + const deliveries = await h.c + .get("db") + .select({ count: sql`count(*)::integer` }) + .from(core_notification_deliveries); + expect(deliveries[0]?.count).toBe(expected); + + const batchesPerWorker = Math.ceil(RECIPIENTS / BATCH); + expect(accessCalls).toBeLessThanOrEqual(batchesPerWorker * 2 + 4); + expect(fanoutQueries).toBeLessThan(RECIPIENTS); + + // eslint-disable-next-line no-console + console.info( + `[1,000 recipients] publish ${publishMs.toFixed(0)} ms, fan-out ${fanoutMs.toFixed(0)} ms, ${fanoutQueries} queries for ${expected} recipients, ${accessCalls} access checks`, + ); + }, 120_000); +}); diff --git a/packages/vitnode/src/api/models/notifications/fanout.ts b/packages/vitnode/src/api/models/notifications/fanout.ts new file mode 100644 index 000000000..7ad93b5a6 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/fanout.ts @@ -0,0 +1,455 @@ +import type { SQL } from "drizzle-orm"; + +import { and, asc, eq, inArray, sql } from "drizzle-orm"; + +import type { RegisteredNotificationType } from "@/api/lib/notifications/registry"; +import type { NotificationStateMessage } from "@/lib/notifications/types"; + +import { resolveNotificationChannels } from "@/api/lib/notifications/preferences"; +import { parseStoredNotificationData } from "@/api/lib/notifications/registry"; +import { + core_notification_deliveries, + core_notification_events, + core_notification_receipts, + core_notification_user_state, + core_notifications, +} from "@/database/notifications"; +import { core_users } from "@/database/users"; + +import type { NotificationEventRow } from "./render"; +import type { + NotificationsContext, + NotificationsDb, + NotificationSettingsSnapshot, +} from "./shared"; + +import { dispatchEmailDrain } from "./email-queue"; +import { checkNotificationAccess } from "./render"; +import { + getNotificationRegistry, + getNotificationWorkers, + isEmailConfigured, + loadNotificationSettings, + sendNotificationStates, +} from "./shared"; + +type StateUpdate = NotificationStateMessage & { userId: number }; + +export const valuesList = (rows: SQL[][]): SQL => + sql.join( + rows.map(row => sql`(${sql.join(row, sql`, `)})`), + sql`, `, + ); + +export const lockUserStates = async ( + tx: NotificationsDb, + userIds: number[], +) => { + const userIdsInLockOrder = [...new Set(userIds)].sort((a, b) => a - b); + if (userIdsInLockOrder.length === 0) return []; + + await tx + .insert(core_notification_user_state) + .values(userIdsInLockOrder.map(userId => ({ userId }))) + .onConflictDoNothing(); + + return await tx + .select({ + preferences: core_notification_user_state.preferences, + userId: core_notification_user_state.userId, + }) + .from(core_notification_user_state) + .where(inArray(core_notification_user_state.userId, userIdsInLockOrder)) + .orderBy(asc(core_notification_user_state.userId)) + .for("update"); +}; + +export const applyUnreadDeltas = async ( + tx: NotificationsDb, + deltas: Map, + reason: NotificationStateMessage["reason"], + notificationId?: number, +): Promise => { + if (deltas.size === 0) return []; + + const byDelta = new Map(); + for (const [userId, delta] of deltas) { + byDelta.set(delta, [...(byDelta.get(delta) ?? []), userId]); + } + + const updates: StateUpdate[] = []; + for (const [delta, userIds] of byDelta) { + const rows = await tx + .update(core_notification_user_state) + .set({ + revision: sql`${core_notification_user_state.revision} + 1`, + unreadCount: sql`GREATEST(0, ${core_notification_user_state.unreadCount} + ${delta})`, + updatedAt: new Date(), + }) + .where(inArray(core_notification_user_state.userId, userIds)) + .returning({ + revision: core_notification_user_state.revision, + unread: core_notification_user_state.unreadCount, + userId: core_notification_user_state.userId, + }); + + updates.push( + ...rows.map(row => ({ + ...row, + reason, + ...(notificationId === undefined ? {} : { notificationId }), + })), + ); + } + + return updates; +}; + +interface Candidates { + done: boolean; + recipientCursor: number; + userIds: number[]; +} + +const nextCandidates = ( + event: NotificationEventRow, + batchSize: number, +): Candidates => { + const userIds = event.recipientIds.slice( + event.recipientCursor, + event.recipientCursor + batchSize, + ); + const recipientCursor = event.recipientCursor + userIds.length; + + return { + done: recipientCursor >= event.recipientIds.length, + recipientCursor, + userIds, + }; +}; + +interface BatchResult { + delivered: number; + done: boolean; + states: StateUpdate[]; +} + +const failEvent = async (tx: NotificationsDb, id: number, error: string) => { + await tx + .update(core_notification_events) + .set({ completedAt: new Date(), lastError: error, status: "failed" }) + .where(eq(core_notification_events.id, id)); +}; + +const filterRecipients = async ({ + c, + candidates, + data, + event, + registered, +}: { + c: NotificationsContext; + candidates: number[]; + data: unknown; + event: NotificationEventRow; + registered: RegisteredNotificationType; +}) => { + const notSelf = candidates.filter( + id => event.allowSelf || id !== event.actorId, + ); + if (notSelf.length === 0) return []; + + const existing = await c + .get("db") + .select({ id: core_users.id }) + .from(core_users) + .where(inArray(core_users.id, notSelf)); + const eligible = existing.map(row => row.id); + if (eligible.length === 0) return []; + + const allowed = await checkNotificationAccess({ + c, + data, + event, + registered, + userIds: eligible, + }); + + return eligible.filter(id => allowed.has(id)); +}; + +const processBatch = async ( + c: NotificationsContext, + tx: NotificationsDb, + eventId: number, + settings: NotificationSettingsSnapshot, +): Promise => { + const [event] = await tx + .select() + .from(core_notification_events) + .where(eq(core_notification_events.id, eventId)) + .for("update"); + + if (!event || event.status === "completed" || event.status === "failed") { + return { delivered: 0, done: true, states: [] }; + } + + const registered = getNotificationRegistry(c).get(event.type); + if (registered?.pluginId !== event.pluginId) { + await failEvent(tx, event.id, "Notification type is not registered."); + + return { delivered: 0, done: true, states: [] }; + } + + const { definition } = registered; + const data = parseStoredNotificationData( + definition, + event.data, + event.schemaVersion, + ); + if (data === null) { + await failEvent(tx, event.id, "Event data no longer matches its schema."); + + return { delivered: 0, done: true, states: [] }; + } + + const candidates = nextCandidates( + event, + getNotificationWorkers(c).fanoutBatchSize, + ); + const eligible = await filterRecipients({ + c, + candidates: candidates.userIds, + data, + event, + registered, + }); + + const now = new Date(); + const states: StateUpdate[] = []; + let delivered = 0; + + const locked = await lockUserStates(tx, eligible); + const emailConfigured = isEmailConfigured(c); + const policy = settings.policies.get(definition.id); + const channels = new Map( + locked.map(row => [ + row.userId, + resolveNotificationChannels({ + definition, + emailConfigured, + policy, + preference: row.preferences[definition.id], + }), + ]), + ); + + const receiving = [...channels].filter( + ([, channel]) => channel.inApp || channel.email !== "none", + ); + + if (receiving.length > 0) { + const inserted = await tx + .insert(core_notification_receipts) + .values( + receiving.map(([userId, channel]) => ({ + createdAt: now, + emailPending: channel.email !== "none", + eventId: event.id, + userId, + })), + ) + .onConflictDoNothing() + .returning({ userId: core_notification_receipts.userId }); + + const firstTimeRecipients = inserted + .map(row => row.userId) + .sort((a, b) => a - b); + const inApp = firstTimeRecipients.filter( + userId => channels.get(userId)?.inApp, + ); + + if (inApp.length > 0) { + const groupKey = event.groupKey ?? `event:${event.id}`; + const groupBucket = + event.groupKey && definition.grouping + ? Math.floor( + event.createdAt.getTime() / + (definition.grouping.windowMinutes * 60_000), + ) + : 0; + + const previous = await tx + .select({ + activitySeq: core_notifications.activitySeq, + archivedAt: core_notifications.archivedAt, + readSeq: core_notifications.readSeq, + userId: core_notifications.userId, + }) + .from(core_notifications) + .where( + and( + inArray(core_notifications.userId, inApp), + eq(core_notifications.pluginId, event.pluginId), + eq(core_notifications.type, event.type), + eq(core_notifications.groupKey, groupKey), + eq(core_notifications.groupBucket, groupBucket), + ), + ); + const wasCounted = new Map( + previous.map(row => [ + row.userId, + row.archivedAt === null && row.readSeq < row.activitySeq, + ]), + ); + + const items = await tx + .insert(core_notifications) + .values( + inApp.map(userId => ({ + activitySeq: 1, + category: definition.category, + createdAt: now, + eventCount: 1, + groupBucket, + groupKey, + lastActivityAt: now, + latestEventId: event.id, + pluginId: event.pluginId, + readSeq: 0, + subjectId: event.subjectId, + subjectType: event.subjectType, + type: event.type, + userId, + })), + ) + .onConflictDoUpdate({ + set: { + activitySeq: sql`${core_notifications.activitySeq} + 1`, + archivedAt: null, + eventCount: sql`${core_notifications.eventCount} + 1`, + lastActivityAt: sql`excluded."lastActivityAt"`, + latestEventId: sql`excluded."latestEventId"`, + }, + target: [ + core_notifications.userId, + core_notifications.pluginId, + core_notifications.type, + core_notifications.groupKey, + core_notifications.groupBucket, + ], + }) + .returning({ + activitySeq: core_notifications.activitySeq, + id: core_notifications.id, + userId: core_notifications.userId, + }); + + await tx.execute(sql` + UPDATE ${core_notification_receipts} AS r + SET "notificationId" = v.nid, "seq" = v.seq + FROM (VALUES ${valuesList( + items.map(item => [ + sql`${item.userId}::integer`, + sql`${item.id}::bigint`, + sql`${item.activitySeq}::integer`, + ]), + )}) AS v(uid, nid, seq) + WHERE r."eventId" = ${event.id} AND r."userId" = v.uid + `); + + const deltas = new Map( + inApp.map(userId => [userId, wasCounted.get(userId) ? 0 : 1]), + ); + states.push(...(await applyUnreadDeltas(tx, deltas, "created"))); + delivered = inApp.length; + } + + const immediate = firstTimeRecipients.filter( + userId => channels.get(userId)?.email === "immediate", + ); + if (immediate.length > 0) { + const deliveries = await tx + .insert(core_notification_deliveries) + .values( + immediate.map(userId => ({ + availableAt: now, + channel: "email" as const, + createdAt: now, + eventId: event.id, + idempotencyKey: `immediate:${event.id}:${userId}`, + mode: "immediate" as const, + updatedAt: now, + userId, + })), + ) + .onConflictDoNothing() + .returning({ + id: core_notification_deliveries.id, + userId: core_notification_deliveries.userId, + }); + + if (deliveries.length > 0) { + await tx.execute(sql` + UPDATE ${core_notification_receipts} AS r + SET "emailDeliveryId" = v.did + FROM (VALUES ${valuesList( + deliveries.map(row => [ + sql`${row.userId}::integer`, + sql`${row.id}::bigint`, + ]), + )}) AS v(uid, did) + WHERE r."eventId" = ${event.id} AND r."userId" = v.uid + `); + await dispatchEmailDrain(c, tx); + } + } + } + + await tx + .update(core_notification_events) + .set({ + completedAt: candidates.done ? now : null, + deliveredCount: sql`${core_notification_events.deliveredCount} + ${delivered}`, + lastError: null, + recipientCursor: candidates.recipientCursor, + status: candidates.done ? "completed" : "processing", + }) + .where(eq(core_notification_events.id, event.id)); + + return { delivered, done: candidates.done, states }; +}; + +interface FanoutResult { + batches: number; + delivered: number; + done: boolean; + paused?: boolean; +} + +export const processNotificationEvent = async ( + c: NotificationsContext, + eventId: number, + { timeBudgetMs = 20_000 }: { timeBudgetMs?: number } = {}, +): Promise => { + const db = c.get("db"); + const settings = await loadNotificationSettings(db); + const deadline = Date.now() + timeBudgetMs; + const progress: FanoutResult = { batches: 0, delivered: 0, done: false }; + if (settings.global.paused) return { ...progress, done: true, paused: true }; + + while (!progress.done) { + const batch = await db.transaction( + async tx => await processBatch(c, tx, eventId, settings), + ); + sendNotificationStates(c, batch.states); + + progress.batches += 1; + progress.delivered += batch.delivered; + progress.done = batch.done; + + if (!progress.done && Date.now() >= deadline) break; + } + + return progress; +}; diff --git a/packages/vitnode/src/api/models/notifications/inbox.integration.test.ts b/packages/vitnode/src/api/models/notifications/inbox.integration.test.ts new file mode 100644 index 000000000..6c6281d94 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/inbox.integration.test.ts @@ -0,0 +1,426 @@ +// @vitest-environment node +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, expect, it } from "vitest"; +import { z } from "zod"; + +import { buildNotificationType } from "@/api/lib/notifications/registry"; +import { + core_notification_user_state, + core_notifications, +} from "@/database/notifications"; +import { + createNotificationsHarness, + type NotificationsHarness, +} from "@/tests/notifications"; +import { describePostgres } from "@/tests/postgres"; + +import { processNotificationEvent } from "./fanout"; +import { + archiveNotification, + getNotificationState, + listNotifications, + markAllNotificationsRead, + markNotificationRead, + markNotificationUnread, + reconcileNotificationCounts, +} from "./inbox"; + +const replyType = buildNotificationType({ + id: "test.reply", + version: 1, + schema: z.object({ threadId: z.number() }), + category: "social", + label: "test.reply.label", + subjectType: "test.thread", + defaults: { email: "none", inApp: true }, + grouping: { windowMinutes: 60 }, + present: ({ actorCount, eventCount }) => ({ + title: `${eventCount} replies from ${actorCount} people`, + target: "/threads/1", + }), +}); + +const pingType = buildNotificationType({ + id: "test.ping", + version: 1, + schema: z.object({ n: z.number() }), + category: "system", + label: "test.ping.label", + defaults: { email: "none", inApp: true }, + present: ({ data }) => ({ title: `Ping ${data.n}` }), +}); + +let revokedUserIds = new Set(); +const privateType = buildNotificationType({ + id: "test.private", + version: 1, + schema: z.object({ secret: z.string() }), + category: "content", + label: "test.private.label", + defaults: { email: "none", inApp: true }, + access: ({ userIds }) => userIds.filter(id => !revokedUserIds.has(id)), + present: ({ data }) => ({ + title: `Secret: ${data.secret}`, + target: "/secret", + }), +}); + +describePostgres("notification inbox state", () => { + let h: NotificationsHarness; + let keySeq = 0; + + beforeAll(async () => { + h = await createNotificationsHarness({ + types: [replyType, pingType, privateType], + }); + }); + + afterAll(async () => { + await h?.database.drop(); + }); + + const ping = async (userId: number, n = keySeq) => { + keySeq += 1; + await h.c.get("notifications").publish({ + actorId: null, + data: { n }, + idempotencyKey: `ping-${keySeq}`, + recipients: [userId], + type: pingType, + }); + await h.drainQueue(); + }; + + const reply = async (userId: number, actorId: number) => { + keySeq += 1; + await h.c.get("notifications").publish({ + actorId, + data: { threadId: 1 }, + idempotencyKey: `reply-${keySeq}`, + recipients: [userId], + subject: { id: 1, type: "test.thread" }, + type: replyType, + }); + await h.drainQueue(); + }; + + const state = async (userId: number) => + await getNotificationState(h.c.get("db"), userId); + + const itemsOf = async (userId: number) => + await h.c + .get("db") + .select() + .from(core_notifications) + .where(eq(core_notifications.userId, userId)); + + it("counts read, unread and archive transitions exactly once", async () => { + const [user] = await h.createUsers(1); + await ping(user); + const [item] = await itemsOf(user); + expect((await state(user)).unread).toBe(1); + + const read = await markNotificationRead(h.c, { + notificationId: item.id, + userId: user, + }); + expect(read.unread).toBe(0); + + const again = await markNotificationRead(h.c, { + notificationId: item.id, + userId: user, + }); + expect(again.unread).toBe(0); + + expect( + ( + await markNotificationUnread(h.c, { + notificationId: item.id, + userId: user, + }) + ).unread, + ).toBe(1); + expect( + ( + await markNotificationUnread(h.c, { + notificationId: item.id, + userId: user, + }) + ).unread, + ).toBe(1); + + expect( + ( + await archiveNotification(h.c, { + notificationId: item.id, + userId: user, + }) + ).unread, + ).toBe(0); + expect(await reconcileNotificationCounts(h.c, [user])).toEqual({ + checked: 1, + corrected: 0, + }); + }); + + it("bumps the revision on every change and pushes the absolute count", async () => { + const [user] = await h.createUsers(1); + h.realtime.length = 0; + + await ping(user); + await ping(user); + const [item] = await itemsOf(user); + await markNotificationRead(h.c, { notificationId: item.id, userId: user }); + + const mine = h.realtime.filter(message => message.userId === user); + expect(mine.map(message => message.data.unread)).toEqual([1, 2, 1]); + expect(mine.map(message => message.data.revision)).toEqual([1, 2, 3]); + expect(mine.map(message => message.data.reason)).toEqual([ + "created", + "created", + "read", + ]); + }); + + it("keeps a group unread when activity arrived after the read boundary", async () => { + const [owner, a, b] = await h.createUsers(3); + await reply(owner, a); + const [rendered] = await itemsOf(owner); + + await reply(owner, b); + const after = await markNotificationRead(h.c, { + notificationId: rendered.id, + throughSeq: rendered.activitySeq, + userId: owner, + }); + + expect(after.unread).toBe(1); + const [item] = await itemsOf(owner); + expect(item.eventCount).toBe(2); + expect(item.readSeq).toBe(1); + expect(item.activitySeq).toBe(2); + + await markNotificationRead(h.c, { + notificationId: item.id, + throughSeq: item.activitySeq, + userId: owner, + }); + expect((await state(owner)).unread).toBe(0); + await reply(owner, a); + expect((await state(owner)).unread).toBe(1); + await reply(owner, b); + expect((await state(owner)).unread).toBe(1); + }); + + it("brings an archived group back as unread on new activity", async () => { + const [owner, actor] = await h.createUsers(2); + await reply(owner, actor); + const [item] = await itemsOf(owner); + await archiveNotification(h.c, { notificationId: item.id, userId: owner }); + expect((await state(owner)).unread).toBe(0); + + await reply(owner, actor); + const [reopened] = await itemsOf(owner); + expect(reopened.archivedAt).toBeNull(); + expect((await state(owner)).unread).toBe(1); + }); + + it("starts a new item once the grouping window has passed", async () => { + const [owner, actor] = await h.createUsers(2); + await reply(owner, actor); + await h.c + .get("db") + .update(core_notifications) + .set({ groupBucket: sql`${core_notifications.groupBucket} - 2` }) + .where(eq(core_notifications.userId, owner)); + await reply(owner, actor); + + expect(await itemsOf(owner)).toHaveLength(2); + expect((await state(owner)).unread).toBe(2); + }); + + it("marks all read at a lock boundary that later deliveries cannot cross", async () => { + const [user] = await h.createUsers(1); + await ping(user); + await ping(user); + + const fanoutInProgress = h.forkContext(); + let releaseUserLock: () => void = () => undefined; + const userLockHeld = new Promise(resolve => { + releaseUserLock = resolve; + }); + const fanoutHoldingUserLock = fanoutInProgress + .get("db") + .transaction(async tx => { + await tx + .select() + .from(core_notification_user_state) + .where(eq(core_notification_user_state.userId, user)) + .for("update"); + await userLockHeld; + }); + + const markAll = markAllNotificationsRead(h.c, { userId: user }); + await new Promise(resolve => setTimeout(resolve, 100)); + releaseUserLock(); + await fanoutHoldingUserLock; + const result = await markAll; + + expect(result.marked).toBe(2); + expect(result.unread).toBe(0); + + await ping(user); + expect((await state(user)).unread).toBe(1); + }); + + it("never loses or double-counts with concurrent deliveries and reads", async () => { + const [user] = await h.createUsers(1); + const forks = [h.forkContext(), h.forkContext(), h.forkContext()]; + + const published = await Promise.all( + Array.from( + { length: 12 }, + async (_, index) => + await forks[index % forks.length].get("notifications").publish({ + actorId: null, + data: { n: index }, + idempotencyKey: `concurrent-${user}-${index}`, + recipients: [user], + type: pingType, + }), + ), + ); + + await Promise.all( + published.map(async ({ eventId }, index) => { + await processNotificationEvent(forks[index % forks.length], eventId); + if (index % 3 === 0) { + const items = await itemsOf(user); + const first = items.at(0); + if (first) { + await markNotificationRead(forks[(index + 1) % forks.length], { + notificationId: first.id, + userId: user, + }); + } + } + }), + ); + + const items = await itemsOf(user); + const actual = items.filter( + item => item.archivedAt === null && item.readSeq < item.activitySeq, + ).length; + + expect(items).toHaveLength(12); + expect((await state(user)).unread).toBe(actual); + expect(await reconcileNotificationCounts(h.c, [user])).toEqual({ + checked: 1, + corrected: 0, + }); + }); + + it("isolates users and hides content after access is revoked", async () => { + const [alice, bob] = await h.createUsers(2); + await h.c.get("notifications").publish({ + actorId: null, + data: { secret: "plans" }, + idempotencyKey: `private-${alice}`, + recipients: [alice], + type: privateType, + }); + await h.drainQueue(); + const [item] = await itemsOf(alice); + + await expect( + markNotificationRead(h.c, { notificationId: item.id, userId: bob }), + ).rejects.toMatchObject({ status: 404 }); + expect( + (await listNotifications(h.c, { limit: 20, userId: bob })).items, + ).toEqual([]); + + const visible = await listNotifications(h.c, { limit: 20, userId: alice }); + expect(visible.items[0]).toMatchObject({ + available: true, + target: "/secret", + title: "Secret: plans", + }); + + revokedUserIds = new Set([alice]); + const hidden = await listNotifications(h.c, { limit: 20, userId: alice }); + revokedUserIds = new Set(); + + expect(hidden.items[0]).toMatchObject({ + actors: [], + available: false, + body: null, + target: null, + title: "This notification is no longer available.", + unread: true, + }); + }); + + it("removes items for a subject and keeps counts right", async () => { + const [owner, actor] = await h.createUsers(2); + await reply(owner, actor); + await ping(owner); + expect((await state(owner)).unread).toBe(2); + + const removed = await h.c + .get("notifications") + .remove({ subject: { id: 1, type: "test.thread" }, userIds: [owner] }); + + expect(removed).toBe(1); + expect((await state(owner)).unread).toBe(1); + }); + + it("repairs a drifted count by reconciliation", async () => { + const [user] = await h.createUsers(1); + await ping(user); + await h.c + .get("db") + .update(core_notification_user_state) + .set({ unreadCount: 42 }) + .where(eq(core_notification_user_state.userId, user)); + + expect(await reconcileNotificationCounts(h.c, [user])).toEqual({ + checked: 1, + corrected: 1, + }); + expect((await state(user)).unread).toBe(1); + }); + + it("paginates with a stable cursor and filters unread", async () => { + const [user] = await h.createUsers(1); + for (let index = 0; index < 5; index++) await ping(user, index); + + const first = await listNotifications(h.c, { limit: 2, userId: user }); + const second = await listNotifications(h.c, { + cursor: first.nextCursor ?? undefined, + limit: 2, + userId: user, + }); + const third = await listNotifications(h.c, { + cursor: second.nextCursor ?? undefined, + limit: 2, + userId: user, + }); + + const ids = [...first.items, ...second.items, ...third.items].map( + item => item.id, + ); + expect(new Set(ids).size).toBe(5); + expect(third.nextCursor).toBeNull(); + + await markNotificationRead(h.c, { + notificationId: first.items[0].id, + userId: user, + }); + const unread = await listNotifications(h.c, { + limit: 20, + unreadOnly: true, + userId: user, + }); + expect(unread.items).toHaveLength(4); + }); +}); diff --git a/packages/vitnode/src/api/models/notifications/inbox.ts b/packages/vitnode/src/api/models/notifications/inbox.ts new file mode 100644 index 000000000..17e25c0f2 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/inbox.ts @@ -0,0 +1,505 @@ +import { and, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import type { + NotificationState, + NotificationStateMessage, + NotificationSubject, +} from "@/lib/notifications/types"; + +import { + core_notification_events, + core_notification_user_state, + core_notifications, +} from "@/database/notifications"; + +import type { NotificationActor } from "./render"; +import type { NotificationsContext, NotificationsDb } from "./shared"; + +import { applyUnreadDeltas, lockUserStates } from "./fanout"; +import { + checkNotificationAccess, + createTranslatorCache, + loadActorSummaries, + presentNotification, + resolveEvent, +} from "./render"; +import { sendNotificationStates } from "./shared"; + +type StateUpdate = NotificationStateMessage & { userId: number }; + +export const UNREAD_SQL = sql`${core_notifications.archivedAt} IS NULL AND ${core_notifications.readSeq} < ${core_notifications.activitySeq}`; + +export interface NotificationListItem { + activitySeq: number; + actorCount: number; + actors: NotificationActor[]; + available: boolean; + body: null | string; + category: string; + createdAt: Date; + eventCount: number; + id: number; + lastActivityAt: Date; + pluginId: string; + readAt: Date | null; + subject: NotificationSubject | null; + target: null | string; + title: string; + type: string; + unread: boolean; +} + +export interface NotificationListPage { + items: NotificationListItem[]; + nextCursor: null | string; +} + +const cursorSchema = z.object({ id: z.number().int(), t: z.number().int() }); + +const encodeCursor = (cursor: z.infer): string => + Buffer.from(JSON.stringify(cursor)).toString("base64url"); + +const parseCursorJson = (raw: string): unknown => { + try { + return JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); + } catch { + return null; + } +}; + +const decodeCursor = (raw: string) => { + const cursor = cursorSchema.safeParse(parseCursorJson(raw)); + if (!cursor.success) { + throw new HTTPException(400, { message: "Invalid pagination cursor." }); + } + + return { id: cursor.data.id, lastActivityAt: new Date(cursor.data.t) }; +}; + +export const getNotificationState = async ( + db: NotificationsDb, + userId: number, +): Promise => { + const [row] = await db + .select({ + revision: core_notification_user_state.revision, + unread: core_notification_user_state.unreadCount, + }) + .from(core_notification_user_state) + .where(eq(core_notification_user_state.userId, userId)) + .limit(1); + + return row ?? { revision: 0, unread: 0 }; +}; + +export const listNotifications = async ( + c: NotificationsContext, + { + category, + cursor, + language, + limit, + type, + unreadOnly, + userId, + }: { + category?: string; + cursor?: string; + language?: string; + limit: number; + type?: string; + unreadOnly?: boolean; + userId: number; + }, +): Promise => { + const after = cursor ? decodeCursor(cursor) : null; + + const rows = await c + .get("db") + .select({ + item: core_notifications, + event: core_notification_events, + }) + .from(core_notifications) + .innerJoin( + core_notification_events, + eq(core_notification_events.id, core_notifications.latestEventId), + ) + .where( + and( + eq(core_notifications.userId, userId), + isNull(core_notifications.archivedAt), + unreadOnly ? UNREAD_SQL : undefined, + category ? eq(core_notifications.category, category) : undefined, + type ? eq(core_notifications.type, type) : undefined, + after + ? or( + lt(core_notifications.lastActivityAt, after.lastActivityAt), + and( + eq(core_notifications.lastActivityAt, after.lastActivityAt), + lt(core_notifications.id, after.id), + ), + ) + : undefined, + ), + ) + .orderBy( + desc(core_notifications.lastActivityAt), + desc(core_notifications.id), + ) + .limit(limit + 1); + + const page = rows.slice(0, limit); + const last = page.at(-1); + const summaries = await loadActorSummaries( + c, + page.map(row => row.item.id), + ); + const translator = createTranslatorCache(c); + const { locale, t } = await translator(language); + + const items = await Promise.all( + page.map(async ({ event, item }): Promise => { + const base = { + activitySeq: item.activitySeq, + category: item.category, + createdAt: item.createdAt, + eventCount: item.eventCount, + id: item.id, + lastActivityAt: item.lastActivityAt, + pluginId: item.pluginId, + readAt: item.readAt, + type: item.type, + unread: item.readSeq < item.activitySeq, + }; + const unavailable: NotificationListItem = { + ...base, + actorCount: 0, + actors: [], + available: false, + body: null, + subject: null, + target: null, + title: t("core.notifications.unavailable"), + }; + + const resolved = resolveEvent(c, event); + if (!resolved) return unavailable; + + try { + const allowed = await checkNotificationAccess({ + c, + data: resolved.data, + event, + registered: resolved.registered, + userIds: [userId], + }); + if (!allowed.has(userId)) return unavailable; + } catch { + return unavailable; + } + + const actors = summaries.get(item.id) ?? { actorCount: 0, actors: [] }; + const presented = presentNotification(c, { + actors, + data: resolved.data, + event, + eventCount: item.eventCount, + locale, + registered: resolved.registered, + t, + }); + if (!presented) return unavailable; + + return { + ...base, + actorCount: actors.actorCount, + actors: actors.actors, + available: true, + body: presented.body ?? null, + subject: + item.subjectType && item.subjectId + ? { id: item.subjectId, type: item.subjectType } + : null, + target: presented.target, + title: presented.title, + }; + }), + ); + + return { + items, + nextCursor: + rows.length > limit && last + ? encodeCursor({ + id: last.item.id, + t: last.item.lastActivityAt.getTime(), + }) + : null, + }; +}; + +type ItemRow = typeof core_notifications.$inferSelect; + +const USERS_PER_RECONCILE_BATCH = 200; +const USERS_PER_REMOVE_BATCH = 50; + +const isCounted = ( + item: Pick, +) => item.archivedAt === null && item.readSeq < item.activitySeq; + +const changeItemUnderUserLock = async ( + c: NotificationsContext, + { + change, + notificationId, + reason, + userId, + }: { + change: (item: ItemRow, now: Date) => null | Partial; + notificationId: number; + reason: NotificationStateMessage["reason"]; + userId: number; + }, +): Promise => { + const { state, update } = await c.get("db").transaction(async tx => { + await lockUserStates(tx, [userId]); + + const [item] = await tx + .select() + .from(core_notifications) + .where( + and( + eq(core_notifications.id, notificationId), + eq(core_notifications.userId, userId), + ), + ) + .limit(1); + if (!item) throw new HTTPException(404); + + const now = new Date(); + const patch = change(item, now); + if (!patch) + return { state: await getNotificationState(tx, userId), update: null }; + + await tx + .update(core_notifications) + .set(patch) + .where(eq(core_notifications.id, item.id)); + + const delta = + Number(isCounted({ ...item, ...patch })) - Number(isCounted(item)); + const [update] = await applyUnreadDeltas( + tx, + new Map([[userId, delta]]), + reason, + item.id, + ); + + return { state: update, update }; + }); + + if (update) sendNotificationStates(c, [update]); + + return { revision: state.revision, unread: state.unread }; +}; + +export const markNotificationRead = async ( + c: NotificationsContext, + args: { notificationId: number; throughSeq?: number; userId: number }, +): Promise => + await changeItemUnderUserLock(c, { + change: (item, now) => { + const boundary = Math.min( + args.throughSeq ?? item.activitySeq, + item.activitySeq, + ); + if (item.readSeq >= boundary) return null; + + return { readAt: now, readSeq: boundary }; + }, + notificationId: args.notificationId, + reason: "read", + userId: args.userId, + }); + +export const markNotificationUnread = async ( + c: NotificationsContext, + args: { notificationId: number; userId: number }, +): Promise => + await changeItemUnderUserLock(c, { + change: item => + item.readSeq < item.activitySeq + ? null + : { readAt: null, readSeq: item.activitySeq - 1 }, + notificationId: args.notificationId, + reason: "unread", + userId: args.userId, + }); + +export const archiveNotification = async ( + c: NotificationsContext, + args: { notificationId: number; userId: number }, +): Promise => + await changeItemUnderUserLock(c, { + change: (item, now) => (item.archivedAt ? null : { archivedAt: now }), + notificationId: args.notificationId, + reason: "archived", + userId: args.userId, + }); + +export const markAllNotificationsRead = async ( + c: NotificationsContext, + { category, userId }: { category?: string; userId: number }, +): Promise => { + const { marked, state, update } = await c.get("db").transaction(async tx => { + await lockUserStates(tx, [userId]); + const now = new Date(); + + const markedItems = await tx + .update(core_notifications) + .set({ readAt: now, readSeq: sql`${core_notifications.activitySeq}` }) + .where( + and( + eq(core_notifications.userId, userId), + UNREAD_SQL, + category ? eq(core_notifications.category, category) : undefined, + ), + ) + .returning({ id: core_notifications.id }); + + if (markedItems.length === 0) { + return { + marked: 0, + state: await getNotificationState(tx, userId), + update: null, + }; + } + + const [update] = await applyUnreadDeltas( + tx, + new Map([[userId, -markedItems.length]]), + "read_all", + ); + + return { marked: markedItems.length, state: update, update }; + }); + + if (update) sendNotificationStates(c, [update]); + + return { marked, revision: state.revision, unread: state.unread }; +}; + +export const reconcileNotificationCounts = async ( + c: NotificationsContext, + userIds: number[], +): Promise<{ checked: number; corrected: number }> => { + let corrected = 0; + const updates: StateUpdate[] = []; + + for ( + let index = 0; + index < userIds.length; + index += USERS_PER_RECONCILE_BATCH + ) { + const chunk = userIds.slice(index, index + USERS_PER_RECONCILE_BATCH); + const changed = await c.get("db").transaction(async tx => { + await lockUserStates(tx, chunk); + + return await tx.execute<{ + revision: number | string; + unread: number; + userId: number; + }>(sql` + UPDATE ${core_notification_user_state} AS s + SET "unreadCount" = actual.count, + "revision" = s."revision" + 1, + "updatedAt" = now() + FROM ( + SELECT u.id AS "userId", count(n.id)::integer AS count + FROM unnest(ARRAY[${sql.join( + chunk.map(id => sql`${id}::integer`), + sql`, `, + )}]) AS u(id) + LEFT JOIN ${core_notifications} AS n + ON n."userId" = u.id + AND n."archivedAt" IS NULL + AND n."readSeq" < n."activitySeq" + GROUP BY u.id + ) AS actual + WHERE s."userId" = actual."userId" + AND s."unreadCount" <> actual.count + RETURNING s."userId" AS "userId", s."unreadCount" AS unread, s."revision" AS revision + `); + }); + + const rows = [...changed]; + corrected += rows.length; + updates.push( + ...rows.map(row => ({ + reason: "reconciled" as const, + revision: Number(row.revision), + unread: row.unread, + userId: row.userId, + })), + ); + } + + sendNotificationStates(c, updates); + + return { checked: userIds.length, corrected }; +}; + +export const removeNotificationItems = async ( + c: NotificationsContext, + { where }: { where: ReturnType }, +): Promise => { + let removed = 0; + + for (;;) { + const targets = await c + .get("db") + .select({ userId: core_notifications.userId }) + .from(core_notifications) + .where(where) + .groupBy(core_notifications.userId) + .limit(USERS_PER_REMOVE_BATCH); + if (targets.length === 0) break; + + const userIds = targets.map(row => row.userId); + const batch = await c.get("db").transaction(async tx => { + await lockUserStates(tx, userIds); + + const deleted = await tx + .delete(core_notifications) + .where(and(where, inArray(core_notifications.userId, userIds))) + .returning({ + activitySeq: core_notifications.activitySeq, + archivedAt: core_notifications.archivedAt, + readSeq: core_notifications.readSeq, + userId: core_notifications.userId, + }); + + const deltas = new Map(); + for (const item of deleted) { + deltas.set( + item.userId, + (deltas.get(item.userId) ?? 0) - Number(isCounted(item)), + ); + } + + return { + count: deleted.length, + updates: await applyUnreadDeltas(tx, deltas, "removed"), + }; + }); + + sendNotificationStates(c, batch.updates); + removed += batch.count; + if (batch.count === 0) break; + } + + return removed; +}; diff --git a/packages/vitnode/src/api/models/notifications/index.ts b/packages/vitnode/src/api/models/notifications/index.ts new file mode 100644 index 000000000..0480eb15f --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/index.ts @@ -0,0 +1,70 @@ +import { and, eq, inArray } from "drizzle-orm"; + +import type { NotificationSubject } from "@/lib/notifications/types"; + +import { processQueueTasksByIds } from "@/api/modules/queue/helpers/process-queue-tasks"; +import { core_notifications } from "@/database/notifications"; + +import type { + PublishNotificationArgs, + PublishNotificationResult, +} from "./publish"; +import type { NotificationsContext } from "./shared"; + +import { getNotificationState, removeNotificationItems } from "./inbox"; +import { publishNotification } from "./publish"; + +export type { PublishNotificationArgs, PublishNotificationResult }; + +export class NotificationsModel { + constructor(c: NotificationsContext) { + this.c = c; + } + + protected readonly c: NotificationsContext; + private readonly queued: number[] = []; + + flushAfterResponse(): void { + const ids = this.queued.splice(0); + if (ids.length === 0) return; + + void processQueueTasksByIds(this.c, ids).catch(async (error: unknown) => { + await this.c + .get("log") + .warn( + `[Notifications] Immediate delivery deferred to the queue: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } + + async publish( + args: PublishNotificationArgs, + ): Promise { + return await publishNotification(this.c, args, queueId => + this.queued.push(queueId), + ); + } + + async remove({ + subject, + type, + userIds, + }: { + subject: NotificationSubject; + type?: string; + userIds?: number[]; + }): Promise { + return await removeNotificationItems(this.c, { + where: and( + eq(core_notifications.subjectType, subject.type), + eq(core_notifications.subjectId, String(subject.id)), + type ? eq(core_notifications.type, type) : undefined, + userIds ? inArray(core_notifications.userId, userIds) : undefined, + ), + }); + } + + async state(userId: number) { + return await getNotificationState(this.c.get("db"), userId); + } +} diff --git a/packages/vitnode/src/api/models/notifications/maintenance.ts b/packages/vitnode/src/api/models/notifications/maintenance.ts new file mode 100644 index 000000000..c89d4b0a5 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/maintenance.ts @@ -0,0 +1,205 @@ +import { and, eq, inArray, isNull, lt, lte, notExists, sql } from "drizzle-orm"; + +import { + core_notification_deliveries, + core_notification_events, + core_notification_receipts, + core_notifications, +} from "@/database/notifications"; +import { core_queue } from "@/database/queue"; + +import type { NotificationsContext, NotificationsDb } from "./shared"; + +import { planNotificationDigests, reclaimStaleDeliveries } from "./email"; +import { dispatchEmailDrain } from "./email-queue"; +import { removeNotificationItems } from "./inbox"; +import { + getNotificationWorkers, + loadNotificationSettings, + NOTIFICATIONS_PLUGIN_ID, + QUEUE_NOTIFICATIONS_FANOUT, +} from "./shared"; + +const DAY = 24 * 60 * 60 * 1000; +const STALLED_EVENT_MINUTES = 10; +const UNCLAIMED_EMAIL_RECEIPT_MAX_DAYS = 14; +const DELETE_BATCH = 1000; + +export const deleteUnreferencedEvents = async ( + db: NotificationsDb, + { createdBefore }: { createdBefore?: Date } = {}, +): Promise => { + let events = 0; + for (;;) { + const deleted = await db + .delete(core_notification_events) + .where( + inArray( + core_notification_events.id, + db + .select({ id: core_notification_events.id }) + .from(core_notification_events) + .where( + and( + createdBefore + ? lt(core_notification_events.createdAt, createdBefore) + : undefined, + inArray(core_notification_events.status, [ + "completed", + "failed", + ]), + notExists( + db + .select({ one: sql`1` }) + .from(core_notifications) + .where( + eq( + core_notifications.latestEventId, + core_notification_events.id, + ), + ), + ), + ), + ) + .limit(DELETE_BATCH), + ), + ) + .returning({ id: core_notification_events.id }); + events += deleted.length; + if (deleted.length < DELETE_BATCH) break; + } + + return events; +}; + +export const runNotificationCleanup = async ( + c: NotificationsContext, + { now = new Date() }: { now?: Date } = {}, +): Promise<{ deliveries: number; events: number; items: number }> => { + const db = c.get("db"); + const { retentionDays } = getNotificationWorkers(c); + const cutoff = new Date(now.getTime() - retentionDays * DAY); + + const items = await removeNotificationItems(c, { + where: lt(core_notifications.lastActivityAt, cutoff), + }); + + const events = await deleteUnreferencedEvents(db, { createdBefore: cutoff }); + + let deliveries = 0; + for (;;) { + const deleted = await db + .delete(core_notification_deliveries) + .where( + inArray( + core_notification_deliveries.id, + db + .select({ id: core_notification_deliveries.id }) + .from(core_notification_deliveries) + .where( + and( + lt(core_notification_deliveries.createdAt, cutoff), + inArray(core_notification_deliveries.status, [ + "sent", + "skipped", + "failed", + ]), + ), + ) + .limit(DELETE_BATCH), + ), + ) + .returning({ id: core_notification_deliveries.id }); + deliveries += deleted.length; + if (deleted.length < DELETE_BATCH) break; + } + + await db + .update(core_notification_receipts) + .set({ emailPending: false }) + .where( + and( + eq(core_notification_receipts.emailPending, true), + isNull(core_notification_receipts.emailDeliveryId), + lt( + core_notification_receipts.createdAt, + new Date(now.getTime() - UNCLAIMED_EMAIL_RECEIPT_MAX_DAYS * DAY), + ), + ), + ); + + return { deliveries, events, items }; +}; + +const recoverStalledEvents = async ( + c: NotificationsContext, + now = new Date(), +): Promise => { + const db = c.get("db"); + const stalled = await db + .select({ id: core_notification_events.id }) + .from(core_notification_events) + .where( + and( + inArray(core_notification_events.status, ["pending", "processing"]), + lt( + core_notification_events.createdAt, + new Date(now.getTime() - STALLED_EVENT_MINUTES * 60_000), + ), + notExists( + db + .select({ one: sql`1` }) + .from(core_queue) + .where( + and( + eq(core_queue.pluginId, NOTIFICATIONS_PLUGIN_ID), + eq(core_queue.name, QUEUE_NOTIFICATIONS_FANOUT), + inArray(core_queue.status, ["pending", "processing"]), + sql`${core_queue.payload}->>'eventId' = ${core_notification_events.id}::text`, + ), + ), + ), + ), + ) + .limit(100); + + for (const { id } of stalled) { + await c.get("queue").dispatch({ + name: QUEUE_NOTIFICATIONS_FANOUT, + payload: { eventId: id }, + pluginId: NOTIFICATIONS_PLUGIN_ID, + priority: 10, + }); + } + + return stalled.length; +}; + +export const runNotificationSchedule = async ( + c: NotificationsContext, + { now = new Date() }: { now?: Date } = {}, +) => { + const { global } = await loadNotificationSettings(c.get("db")); + if (global.paused) { + return { digests: { deliveries: 0, users: 0 }, reclaimed: 0, recovered: 0 }; + } + + const reclaimed = await reclaimStaleDeliveries(c, now); + const recovered = await recoverStalledEvents(c, now); + const digests = await planNotificationDigests(c, { now }); + + const [due] = await c + .get("db") + .select({ id: core_notification_deliveries.id }) + .from(core_notification_deliveries) + .where( + and( + eq(core_notification_deliveries.status, "pending"), + lte(core_notification_deliveries.availableAt, now), + ), + ) + .limit(1); + if (due) await dispatchEmailDrain(c); + + return { digests, reclaimed, recovered }; +}; diff --git a/packages/vitnode/src/api/models/notifications/preferences.ts b/packages/vitnode/src/api/models/notifications/preferences.ts new file mode 100644 index 000000000..559b0ebff --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/preferences.ts @@ -0,0 +1,213 @@ +import { eq, sql } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import type { NotificationTypePreference } from "@/database/notifications"; +import type { NotificationEmailMode } from "@/lib/notifications/types"; + +import { + isNotificationEmailAvailable, + isNotificationInAppAvailable, + isNotificationLockedForMembers, + isNotificationPushAvailable, + resolveNotificationChannels, +} from "@/api/lib/notifications/preferences"; +import { core_notification_user_state } from "@/database/notifications"; + +import type { NotificationsContext } from "./shared"; + +import { lockUserStates } from "./fanout"; +import { createTranslatorCache } from "./render"; +import { + getNotificationRegistry, + isEmailConfigured, + loadNotificationSettings, +} from "./shared"; + +export interface NotificationTypePreferenceView { + category: string; + categoryLabel: string; + defaultEmail: NotificationEmailMode; + description: null | string; + emailModes: NotificationEmailMode[]; + id: string; + inAppAvailable: boolean; + label: string; + locked: boolean; + mandatory: boolean; + pluginId: string; + pushAvailable: boolean; + value: { email: NotificationEmailMode; inApp: boolean; push: boolean }; +} + +export interface NotificationPreferencesView { + types: NotificationTypePreferenceView[]; +} + +export const translateOr = ( + t: Awaited>>["t"], + keys: string[], + fallback: string, +): string => { + for (const key of keys) { + if (t.has(key)) return t(key); + } + + return fallback; +}; + +export const getNotificationPreferences = async ( + c: NotificationsContext, + { language, userId }: { language?: string; userId: number }, +): Promise => { + const db = c.get("db"); + const [settings, [state], { t }] = await Promise.all([ + loadNotificationSettings(db), + db + .select() + .from(core_notification_user_state) + .where(eq(core_notification_user_state.userId, userId)) + .limit(1), + createTranslatorCache(c)(language), + ]); + const emailConfigured = isEmailConfigured(c); + const preferences = state?.preferences ?? {}; + + const types = getNotificationRegistry(c) + .list.filter(({ definition }) => { + const policy = settings.policies.get(definition.id); + if (definition.mandatory) return true; + + return ( + policy?.enabled !== false && + (isNotificationInAppAvailable({ definition, policy }) || + isNotificationEmailAvailable({ definition, emailConfigured, policy })) + ); + }) + .map(({ definition, pluginId }): NotificationTypePreferenceView => { + const policy = settings.policies.get(definition.id); + const locked = isNotificationLockedForMembers({ definition, policy }); + const emailAvailable = + isNotificationEmailAvailable({ definition, emailConfigured, policy }) && + !locked; + + return { + category: definition.category, + categoryLabel: translateOr( + t, + [ + `core.notifications.categories.${definition.category}`, + `${pluginId}.notifications.categories.${definition.category}`, + ], + definition.category, + ), + defaultEmail: resolveNotificationChannels({ + definition, + emailConfigured, + policy, + }).email, + description: definition.description + ? translateOr(t, [definition.description], definition.description) + : null, + emailModes: emailAvailable + ? ["none", "immediate", "daily", "weekly"] + : [], + id: definition.id, + inAppAvailable: isNotificationInAppAvailable({ definition, policy }), + label: translateOr(t, [definition.label], definition.id), + locked, + mandatory: !!definition.mandatory, + pluginId, + pushAvailable: isNotificationPushAvailable(policy), + value: resolveNotificationChannels({ + definition, + emailConfigured, + policy, + preference: preferences[definition.id], + }), + }; + }); + + return { + types, + }; +}; + +export interface UpdateNotificationPreferencesArgs { + types?: Record; +} + +export const updateNotificationPreferences = async ( + c: NotificationsContext, + userId: number, + args: UpdateNotificationPreferencesArgs, +): Promise => { + const registry = getNotificationRegistry(c); + const db = c.get("db"); + const settings = await loadNotificationSettings(db); + const emailConfigured = isEmailConfigured(c); + + const patch: Record = {}; + for (const [typeId, preference] of Object.entries(args.types ?? {})) { + const registered = registry.get(typeId); + if (!registered) { + throw new HTTPException(400, { + message: `Unknown notification type "${typeId}".`, + }); + } + const { definition } = registered; + const policy = settings.policies.get(typeId); + if (isNotificationLockedForMembers({ definition, policy })) { + throw new HTTPException(400, { + message: `"${typeId}" cannot be changed.`, + }); + } + if ( + preference.inApp === true && + !isNotificationInAppAvailable({ definition, policy }) + ) { + throw new HTTPException(400, { + message: `"${typeId}" is not shown in the notification list.`, + }); + } + if ( + preference.email !== undefined && + preference.email !== "none" && + !isNotificationEmailAvailable({ definition, emailConfigured, policy }) + ) { + throw new HTTPException(400, { + message: `"${typeId}" has no email channel.`, + }); + } + if (preference.push === true && !isNotificationPushAvailable(policy)) { + throw new HTTPException(400, { + message: `"${typeId}" is not sent as push.`, + }); + } + patch[typeId] = { + ...(preference.email === undefined ? {} : { email: preference.email }), + ...(preference.inApp === undefined ? {} : { inApp: preference.inApp }), + ...(preference.push === undefined ? {} : { push: preference.push }), + }; + } + + await db.transaction(async tx => { + const [current] = await lockUserStates(tx, [userId]); + const merged = { ...current.preferences }; + for (const [typeId, preference] of Object.entries(patch)) { + merged[typeId] = { ...merged[typeId], ...preference }; + } + + await tx + .update(core_notification_user_state) + .set({ + preferences: sql`${JSON.stringify(merged)}::jsonb`, + updatedAt: new Date(), + }) + .where(eq(core_notification_user_state.userId, userId)); + }); + + await c.get("events").emit("notifications.preferences_updated", { + typeIds: Object.keys(patch), + userId, + }); +}; diff --git a/packages/vitnode/src/api/models/notifications/publish.ts b/packages/vitnode/src/api/models/notifications/publish.ts new file mode 100644 index 000000000..80f8975c5 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/publish.ts @@ -0,0 +1,209 @@ +import { and, eq } from "drizzle-orm"; + +import type { + AnyNotificationTypeDefinition, + NotificationTypeDefinition, +} from "@/api/lib/notifications/registry"; +import type { NotificationSubject } from "@/lib/notifications/types"; + +import { SUBJECT_TYPE_PATTERN } from "@/api/lib/notifications/registry"; +import { core_notification_events } from "@/database/notifications"; + +import type { NotificationsContext, NotificationsDb } from "./shared"; + +import { + getNotificationRegistry, + NOTIFICATIONS_PLUGIN_ID, + QUEUE_NOTIFICATIONS_FANOUT, +} from "./shared"; + +const MAX_EXPLICIT_RECIPIENTS = 100_000; + +export interface PublishNotificationArgs { + actorId?: null | number; + allowSelf?: boolean; + data: NoInfer; + idempotencyKey: string; + recipients?: readonly number[]; + subject?: NotificationSubject; + tx?: NotificationsDb; + type: NotificationTypeDefinition | string; +} + +export interface PublishNotificationResult { + duplicate: boolean; + eventId: number; +} + +export class NotificationPublishError extends Error { + constructor(message: string) { + super(`[Notifications] ${message}`); + this.name = "NotificationPublishError"; + } +} + +const normalizeSubject = ( + subject: NotificationSubject, +): { id: string; type: string } => { + const id = String(subject.id); + if (!SUBJECT_TYPE_PATTERN.test(subject.type) || subject.type.length > 100) { + throw new NotificationPublishError( + `Invalid subject type "${subject.type}".`, + ); + } + if (id.length === 0 || id.length > 100) { + throw new NotificationPublishError(`Invalid subject id "${id}".`); + } + + return { id, type: subject.type }; +}; + +const resolveGroupKey = ( + definition: AnyNotificationTypeDefinition, + data: unknown, + subject: null | { id: string; type: string }, +): null | string => { + if (!definition.grouping) return null; + const key = definition.grouping.key + ? definition.grouping.key({ data, subject }) + : subject + ? `${subject.type}:${subject.id}` + : null; + + return key ? key.slice(0, 200) : null; +}; + +export const publishNotification = async ( + c: NotificationsContext, + args: PublishNotificationArgs, + onQueued: (queueId: number) => void, +): Promise => { + const typeId = typeof args.type === "string" ? args.type : args.type.id; + const registered = getNotificationRegistry(c).get(typeId); + if (!registered) { + throw new NotificationPublishError( + `Notification type "${typeId}" is not registered. Add it to a plugin's "notificationTypes".`, + ); + } + if (typeof args.type !== "string" && args.type !== registered.definition) { + throw new NotificationPublishError( + `Notification type "${typeId}" was published with a definition other than the registered one.`, + ); + } + + const { definition, pluginId } = registered; + const parsed = definition.schema.safeParse(args.data); + if (!parsed.success) { + throw new NotificationPublishError( + `Invalid data for "${typeId}": ${parsed.error.issues + .map(issue => `${issue.path.join(".") || "(root)"} ${issue.message}`) + .join("; ")}`, + ); + } + + const idempotencyKey = args.idempotencyKey.trim(); + if (idempotencyKey.length === 0 || idempotencyKey.length > 255) { + throw new NotificationPublishError( + "idempotencyKey must be 1-255 characters.", + ); + } + + const subject = args.subject ? normalizeSubject(args.subject) : null; + if ( + subject && + definition.subjectType && + subject.type !== definition.subjectType + ) { + throw new NotificationPublishError( + `"${typeId}" is about "${definition.subjectType}" subjects, not "${subject.type}".`, + ); + } + + const actorId = + args.actorId === undefined + ? (c.get("admin")?.user.id ?? c.get("user")?.id ?? null) + : args.actorId; + + const recipientIds = [ + ...new Set( + (args.recipients ?? []).filter( + id => + Number.isInteger(id) && + id > 0 && + (args.allowSelf === true || id !== actorId), + ), + ), + ].sort((a, b) => a - b); + + if (recipientIds.length > MAX_EXPLICIT_RECIPIENTS) { + throw new NotificationPublishError( + `At most ${MAX_EXPLICIT_RECIPIENTS} recipients per event.`, + ); + } + + const db = args.tx ?? c.get("db"); + const hasAudience = recipientIds.length > 0; + const now = new Date(); + + const [created] = await db + .insert(core_notification_events) + .values({ + actorId, + allowSelf: args.allowSelf ?? false, + completedAt: hasAudience ? null : now, + createdAt: now, + data: parsed.data as Record, + groupKey: resolveGroupKey(definition, parsed.data, subject), + idempotencyKey, + pluginId, + recipientIds, + schemaVersion: definition.version, + status: hasAudience ? "pending" : "completed", + subjectId: subject?.id ?? null, + subjectType: subject?.type ?? null, + type: typeId, + }) + .onConflictDoNothing({ + target: [ + core_notification_events.pluginId, + core_notification_events.type, + core_notification_events.idempotencyKey, + ], + }) + .returning({ id: core_notification_events.id }); + + if (!created) { + const [existing] = await db + .select({ id: core_notification_events.id }) + .from(core_notification_events) + .where( + and( + eq(core_notification_events.pluginId, pluginId), + eq(core_notification_events.type, typeId), + eq(core_notification_events.idempotencyKey, idempotencyKey), + ), + ) + .limit(1); + + if (!existing) { + throw new NotificationPublishError( + `Event "${typeId}" with key "${idempotencyKey}" is being published concurrently.`, + ); + } + + return { duplicate: true, eventId: existing.id }; + } + + if (hasAudience) { + const queued = await c.get("queue").dispatch({ + name: QUEUE_NOTIFICATIONS_FANOUT, + payload: { eventId: created.id }, + pluginId: NOTIFICATIONS_PLUGIN_ID, + priority: 10, + tx: db, + }); + onQueued(queued.id); + } + + return { duplicate: false, eventId: created.id }; +}; diff --git a/packages/vitnode/src/api/models/notifications/render.ts b/packages/vitnode/src/api/models/notifications/render.ts new file mode 100644 index 000000000..cdb4bccce --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/render.ts @@ -0,0 +1,282 @@ +import { and, eq, inArray, isNotNull, sql } from "drizzle-orm"; + +import type { + NotificationEmailPresentation, + NotificationPresentation, + RegisteredNotificationType, +} from "@/api/lib/notifications/registry"; +import type { Translator } from "@/api/models/i18n"; +import type { NotificationSubject } from "@/lib/notifications/types"; + +import { parseStoredNotificationData } from "@/api/lib/notifications/registry"; +import { + plainNotificationText, + safeNotificationTarget, +} from "@/api/lib/notifications/safe"; +import { storageUrlOf } from "@/api/lib/storage-url"; +import { core_files } from "@/database/files"; +import { + core_notification_events, + core_notification_receipts, +} from "@/database/notifications"; +import { core_users } from "@/database/users"; + +import type { NotificationsContext } from "./shared"; + +import { getNotificationRegistry } from "./shared"; + +export type NotificationEventRow = typeof core_notification_events.$inferSelect; + +export interface NotificationActor { + avatarColor: string; + avatarUrl: null | string; + id: number; + name: string; + nameCode: string; +} + +interface ActorSummary { + actorCount: number; + actors: NotificationActor[]; +} + +const ACTOR_PREVIEW_LIMIT = 3; + +export const loadActors = async ( + c: NotificationsContext, + ids: number[], +): Promise> => { + const unique = [...new Set(ids)]; + if (unique.length === 0) return new Map(); + + const rows = await c + .get("db") + .select({ + avatarColor: core_users.avatarColor, + avatarKey: core_files.key, + id: core_users.id, + name: core_users.name, + nameCode: core_users.nameCode, + }) + .from(core_users) + .leftJoin(core_files, eq(core_files.id, core_users.avatarId)) + .where(inArray(core_users.id, unique)); + + return new Map( + rows.map(({ avatarKey, ...row }) => [ + row.id, + { ...row, avatarUrl: storageUrlOf(c, avatarKey) }, + ]), + ); +}; + +export const loadActorSummaries = async ( + c: NotificationsContext, + notificationIds: number[], +): Promise> => { + if (notificationIds.length === 0) return new Map(); + + const rows = await c + .get("db") + .select({ + actorId: core_notification_events.actorId, + notificationId: core_notification_receipts.notificationId, + seq: sql`max(${core_notification_receipts.seq})`.mapWith(Number), + }) + .from(core_notification_receipts) + .innerJoin( + core_notification_events, + eq(core_notification_events.id, core_notification_receipts.eventId), + ) + .where( + and( + inArray(core_notification_receipts.notificationId, notificationIds), + isNotNull(core_notification_events.actorId), + ), + ) + .groupBy( + core_notification_receipts.notificationId, + core_notification_events.actorId, + ); + + const byItem = new Map(); + for (const row of rows) { + if (row.notificationId === null || row.actorId === null) continue; + byItem.set(row.notificationId, [ + ...(byItem.get(row.notificationId) ?? []), + { actorId: row.actorId, seq: row.seq }, + ]); + } + + const previewIds = [...byItem.values()].flatMap(list => + list + .sort((a, b) => b.seq - a.seq) + .slice(0, ACTOR_PREVIEW_LIMIT) + .map(entry => entry.actorId), + ); + const actors = await loadActors(c, previewIds); + + return new Map( + [...byItem].map(([notificationId, list]) => [ + notificationId, + { + actorCount: list.length, + actors: list + .slice(0, ACTOR_PREVIEW_LIMIT) + .map(entry => actors.get(entry.actorId)) + .filter(actor => actor !== undefined), + }, + ]), + ); +}; + +const subjectOf = ( + row: Pick, +): NotificationSubject | null => + row.subjectType && row.subjectId + ? { id: row.subjectId, type: row.subjectType } + : null; + +export const checkNotificationAccess = async ({ + c, + data, + event, + registered, + userIds, +}: { + c: NotificationsContext; + data: unknown; + event: NotificationEventRow; + registered: RegisteredNotificationType; + userIds: number[]; +}): Promise> => { + const { access } = registered.definition; + if (!access) return new Set(userIds); + + return new Set(await access({ c, data, subject: subjectOf(event), userIds })); +}; + +interface ResolvedEvent { + data: unknown; + registered: RegisteredNotificationType; +} + +export const resolveEvent = ( + c: NotificationsContext, + event: NotificationEventRow, +): null | ResolvedEvent => { + const registered = getNotificationRegistry(c).get(event.type); + if (registered?.pluginId !== event.pluginId) return null; + + const data = parseStoredNotificationData( + registered.definition, + event.data, + event.schemaVersion, + ); + + return data === null ? null : { data, registered }; +}; + +interface PresentArgs { + actors: ActorSummary; + data: unknown; + event: NotificationEventRow; + eventCount: number; + locale: string; + registered: RegisteredNotificationType; + t: Translator; +} + +const presentArgs = ({ + actors, + data, + event, + eventCount, + locale, + t, +}: PresentArgs) => ({ + actorCount: actors.actorCount, + actors: actors.actors.map(actor => ({ id: actor.id, name: actor.name })), + data, + eventCount, + locale, + subject: subjectOf(event), + t, +}); + +export const presentNotification = ( + c: NotificationsContext, + args: PresentArgs, +): (NotificationPresentation & { target: null | string }) | null => { + try { + const presented = args.registered.definition.present(presentArgs(args)); + const title = plainNotificationText(presented.title); + if (!title) return null; + + return { + body: plainNotificationText(presented.body) || undefined, + target: safeNotificationTarget(presented.target), + title, + }; + } catch (error) { + void c + .get("log") + .error( + `[Notifications] "${args.registered.definition.id}" failed to render: ${error instanceof Error ? error.message : String(error)}`, + ); + + return null; + } +}; + +export const presentNotificationEmail = ( + c: NotificationsContext, + args: PresentArgs, +): (NotificationEmailPresentation & { target: null | string }) | null => { + const inApp = presentNotification(c, args); + if (!inApp) return null; + + const { email } = args.registered.definition; + if (typeof email !== "function") { + return { + body: inApp.body, + subject: inApp.title, + target: inApp.target, + title: inApp.title, + }; + } + + try { + const presented = email(presentArgs(args)); + + return { + actionLabel: plainNotificationText(presented.actionLabel) || undefined, + body: plainNotificationText(presented.body) || undefined, + subject: plainNotificationText(presented.subject) || inApp.title, + target: inApp.target, + title: plainNotificationText(presented.title) || inApp.title, + }; + } catch { + return { + body: inApp.body, + subject: inApp.title, + target: inApp.target, + title: inApp.title, + }; + } +}; + +export const createTranslatorCache = (c: NotificationsContext) => { + const cache = new Map>(); + + return async (language: null | string | undefined) => { + const locale = c.get("i18n").resolveSupportedLocale(language ?? undefined); + let translator = cache.get(locale); + if (!translator) { + translator = c.get("i18n").getTranslator(locale); + cache.set(locale, translator); + } + + return { locale, t: await translator }; + }; +}; diff --git a/packages/vitnode/src/api/models/notifications/shared.ts b/packages/vitnode/src/api/models/notifications/shared.ts new file mode 100644 index 000000000..9050d77d8 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/shared.ts @@ -0,0 +1,89 @@ +import type { Context } from "hono"; + +import type { + NotificationGlobalSettings, + NotificationTypePolicy, +} from "@/api/lib/notifications/preferences"; +import type { NotificationRegistry } from "@/api/lib/notifications/registry"; +import type { EnvVitNode } from "@/api/middlewares/global.middleware"; +import type { NotificationStateMessage } from "@/lib/notifications/types"; + +import { + DEFAULT_NOTIFICATION_SETTINGS, + type NotificationWorkerSettings, + resolveNotificationWorkerSettings, +} from "@/api/lib/notifications/preferences"; +import { core_notification_settings } from "@/database/notifications"; +import { notificationsStateChannel } from "@/ws/notifications"; + +export type NotificationsDb = Omit; + +export type NotificationsContext = Context; + +export const NOTIFICATIONS_PLUGIN_ID = "@vitnode/core"; +export const QUEUE_NOTIFICATIONS_FANOUT = "notifications-fanout"; +export const QUEUE_NOTIFICATIONS_EMAIL = "notifications-email"; +export const QUEUE_NOTIFICATIONS_CLEANUP = "notifications-cleanup"; + +export const GLOBAL_SETTINGS_KEY = "global"; +const TYPE_SETTINGS_KEY_PREFIX = "type:"; +export const typeSettingsKey = (type: string): string => + `${TYPE_SETTINGS_KEY_PREFIX}${type}`; + +export const getNotificationRegistry = ( + c: NotificationsContext, +): NotificationRegistry => { + const registry = c.get("core").notifications; + if (!registry) throw new Error("[Notifications] Registry not initialized."); + + return registry; +}; + +export interface NotificationSettingsSnapshot { + global: NotificationGlobalSettings; + policies: Map; +} + +const normalizeGlobalSettings = ( + value: Record | undefined, +): NotificationGlobalSettings => ({ + paused: + typeof value?.paused === "boolean" + ? value.paused + : DEFAULT_NOTIFICATION_SETTINGS.paused, +}); + +export const loadNotificationSettings = async ( + db: NotificationsDb, +): Promise => { + const rows = await db.select().from(core_notification_settings); + const policies = new Map(); + let global: Record | undefined; + + for (const row of rows) { + if (row.key === GLOBAL_SETTINGS_KEY) global = row.value; + else if (row.key.startsWith(TYPE_SETTINGS_KEY_PREFIX)) { + policies.set(row.key.slice(TYPE_SETTINGS_KEY_PREFIX.length), row.value); + } + } + + return { global: normalizeGlobalSettings(global), policies }; +}; + +export const getNotificationWorkers = ( + c: NotificationsContext, +): NotificationWorkerSettings => + c.get("core").notificationWorkers ?? resolveNotificationWorkerSettings(); + +export const isEmailConfigured = (c: NotificationsContext): boolean => + !!c.get("core").email?.adapter; + +export const sendNotificationStates = ( + c: NotificationsContext, + states: Iterable, +): void => { + const realtime = c.get("realtime"); + for (const { userId, ...message } of states) { + realtime.sendToUser(userId, notificationsStateChannel, message); + } +}; diff --git a/packages/vitnode/src/api/models/notifications/stats.test.ts b/packages/vitnode/src/api/models/notifications/stats.test.ts new file mode 100644 index 000000000..2987e68b2 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/stats.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { buildNotificationStatsBuckets, foldNotificationStats } from "./stats"; + +describe("buildNotificationStatsBuckets", () => { + it("returns the last 7 local days and the 7 before them", () => { + const buckets = buildNotificationStatsBuckets({ + now: new Date("2026-10-04T10:00:00Z"), + range: "7d", + timeZone: "UTC", + }); + + expect(buckets.current).toEqual([ + "2026-09-28", + "2026-09-29", + "2026-09-30", + "2026-10-01", + "2026-10-02", + "2026-10-03", + "2026-10-04", + ]); + expect(buckets.previous[0]).toBe("2026-09-21"); + expect(buckets.previous.at(-1)).toBe("2026-09-27"); + }); + + it("counts days in the viewer's time zone, not UTC", () => { + const { current } = buildNotificationStatsBuckets({ + now: new Date("2026-10-04T23:30:00Z"), + range: "7d", + timeZone: "Europe/Warsaw", + }); + + expect(current.at(-1)).toBe("2026-10-05"); + }); + + it("returns 24 hourly buckets ending with the current hour", () => { + const { current, previous } = buildNotificationStatsBuckets({ + now: new Date("2026-10-04T10:15:00Z"), + range: "24h", + timeZone: "UTC", + }); + + expect(current).toHaveLength(24); + expect(previous).toHaveLength(24); + expect(current[0]).toBe("2026-10-03T11"); + expect(current.at(-1)).toBe("2026-10-04T10"); + }); +}); + +describe("foldNotificationStats", () => { + it("fills empty buckets with zeros and totals both periods", () => { + const result = foldNotificationStats( + [ + { count: 5, key: "2026-10-02", metric: "sent" }, + { count: 2, key: "2026-10-02", metric: "failed" }, + { count: 3, key: "2026-10-03", metric: "events" }, + { count: 9, key: "2026-09-30", metric: "sent" }, + ], + { + current: ["2026-10-02", "2026-10-03"], + previous: ["2026-09-30", "2026-10-01"], + }, + ); + + expect(result.points).toEqual([ + { events: 0, failed: 2, key: "2026-10-02", sent: 5, skipped: 0 }, + { events: 3, failed: 0, key: "2026-10-03", sent: 0, skipped: 0 }, + ]); + expect(result.totals).toEqual({ + events: 3, + failed: 2, + sent: 5, + skipped: 0, + }); + expect(result.previous).toEqual({ + events: 0, + failed: 0, + sent: 9, + skipped: 0, + }); + }); +}); diff --git a/packages/vitnode/src/api/models/notifications/stats.ts b/packages/vitnode/src/api/models/notifications/stats.ts new file mode 100644 index 000000000..c10e6eaf9 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/stats.ts @@ -0,0 +1,243 @@ +import { + and, + gte, + inArray, + isNull, + ne, + or, + sql, + type SQLWrapper, +} from "drizzle-orm"; + +import { + core_notification_deliveries, + core_notification_events, +} from "@/database/notifications"; + +import type { NotificationsContext } from "./shared"; + +export const NOTIFICATION_STATS_RANGES = ["24h", "7d", "30d"] as const; +export type NotificationStatsRange = (typeof NOTIFICATION_STATS_RANGES)[number]; + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +const RANGE_SPEC = { + "24h": { count: 24, unit: "hour" }, + "30d": { count: 30, unit: "day" }, + "7d": { count: 7, unit: "day" }, +} as const satisfies Record< + NotificationStatsRange, + { count: number; unit: "day" | "hour" } +>; + +export interface NotificationStatsTotals { + events: number; + failed: number; + sent: number; + skipped: number; +} + +export interface NotificationStatsPoint extends NotificationStatsTotals { + key: string; +} + +export interface NotificationStats { + points: NotificationStatsPoint[]; + previous: NotificationStatsTotals; + range: NotificationStatsRange; + timeZone: string; + totals: NotificationStatsTotals; + unit: "day" | "hour"; +} + +export interface NotificationStatsRow { + count: number; + key: string; + metric: keyof NotificationStatsTotals; +} + +const localParts = (date: Date, timeZone: string) => { + const parts = new Intl.DateTimeFormat("en-US", { + day: "2-digit", + hour: "2-digit", + hourCycle: "h23", + month: "2-digit", + timeZone, + year: "numeric", + }).formatToParts(date); + const get = (type: Intl.DateTimeFormatPartTypes) => + parts.find(part => part.type === type)?.value ?? "00"; + + return { + day: get("day"), + hour: get("hour"), + month: get("month"), + year: get("year"), + }; +}; + +const dayKey = (year: number, monthIndex: number, day: number) => + new Date(Date.UTC(year, monthIndex, day)).toISOString().slice(0, 10); + +export const buildNotificationStatsBuckets = ({ + now, + range, + timeZone, +}: { + now: Date; + range: NotificationStatsRange; + timeZone: string; +}): { current: string[]; previous: string[] } => { + const { count, unit } = RANGE_SPEC[range]; + const keys: string[] = []; + + if (unit === "day") { + const today = localParts(now, timeZone); + for (let offset = count * 2 - 1; offset >= 0; offset -= 1) { + keys.push( + dayKey( + Number(today.year), + Number(today.month) - 1, + Number(today.day) - offset, + ), + ); + } + } else { + const seen = new Set(); + for (let offset = count * 2 + 1; offset >= 0; offset -= 1) { + const parts = localParts( + new Date(now.getTime() - offset * HOUR), + timeZone, + ); + const key = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}`; + if (!seen.has(key)) { + seen.add(key); + keys.push(key); + } + } + keys.splice(0, keys.length - count * 2); + } + + return { current: keys.slice(count), previous: keys.slice(0, count) }; +}; + +const emptyTotals = (): NotificationStatsTotals => ({ + events: 0, + failed: 0, + sent: 0, + skipped: 0, +}); + +export const foldNotificationStats = ( + rows: NotificationStatsRow[], + buckets: { current: string[]; previous: string[] }, +): Pick => { + const byKey = new Map(); + for (const row of rows) { + const totals = byKey.get(row.key) ?? emptyTotals(); + totals[row.metric] += row.count; + byKey.set(row.key, totals); + } + + const sum = (keys: string[]) => + keys.reduce((acc, key) => { + const bucket = byKey.get(key); + if (!bucket) return acc; + + return { + events: acc.events + bucket.events, + failed: acc.failed + bucket.failed, + sent: acc.sent + bucket.sent, + skipped: acc.skipped + bucket.skipped, + }; + }, emptyTotals()); + + return { + points: buckets.current.map(key => ({ + key, + ...(byKey.get(key) ?? emptyTotals()), + })), + previous: sum(buckets.previous), + totals: sum(buckets.current), + }; +}; + +export const getNotificationStats = async ( + c: NotificationsContext, + { + now = new Date(), + range, + timeZone, + }: { now?: Date; range: NotificationStatsRange; timeZone: string }, +): Promise => { + const db = c.get("db"); + const { count, unit } = RANGE_SPEC[range]; + const buckets = buildNotificationStatsBuckets({ now, range, timeZone }); + const since = new Date( + now.getTime() - (count * 2 + 2) * (unit === "day" ? DAY : HOUR), + ); + const format = unit === "day" ? "YYYY-MM-DD" : 'YYYY-MM-DD"T"HH24'; + const bucketOf = (column: SQLWrapper) => + sql`to_char(date_trunc(${unit}, (${column} AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}), ${format})`; + const finishedAt = sql`coalesce(${core_notification_deliveries.sentAt}, ${core_notification_deliveries.updatedAt})`; + + const [deliveryRows, eventRows] = await Promise.all([ + db + .select({ + count: sql`count(*)::integer`, + key: bucketOf(finishedAt), + status: core_notification_deliveries.status, + }) + .from(core_notification_deliveries) + .where( + and( + inArray(core_notification_deliveries.status, [ + "sent", + "failed", + "skipped", + ]), + ne(core_notification_deliveries.mode, "test"), + or( + gte(core_notification_deliveries.sentAt, since), + and( + isNull(core_notification_deliveries.sentAt), + gte(core_notification_deliveries.updatedAt, since), + ), + ), + ), + ) + .groupBy(sql`2`, core_notification_deliveries.status), + db + .select({ + count: sql`count(*)::integer`, + key: bucketOf(core_notification_events.createdAt), + }) + .from(core_notification_events) + .where(gte(core_notification_events.createdAt, since)) + .groupBy(sql`2`), + ]); + + const rows: NotificationStatsRow[] = [ + ...deliveryRows.map(row => ({ + count: row.count, + key: row.key, + metric: + row.status === "sent" || row.status === "failed" + ? row.status + : ("skipped" as const), + })), + ...eventRows.map(row => ({ + count: row.count, + key: row.key, + metric: "events" as const, + })), + ]; + + return { + ...foldNotificationStats(rows, buckets), + range, + timeZone, + unit, + }; +}; diff --git a/packages/vitnode/src/api/models/session-revoke.ts b/packages/vitnode/src/api/models/session-revoke.ts index 0055a877f..9756df0c8 100644 --- a/packages/vitnode/src/api/models/session-revoke.ts +++ b/packages/vitnode/src/api/models/session-revoke.ts @@ -116,4 +116,9 @@ export const revokeSessions = async ( ]); await deleteSessionCacheKeys(c, { adminSessions, sessions }); + await c.get("events").emit("user.sessions.revoked", { + deviceId: scope.deviceId ?? null, + sessions: sessions.length + adminSessions.length, + userId: scope.userId, + }); }; diff --git a/packages/vitnode/src/api/models/sso-connection.ts b/packages/vitnode/src/api/models/sso-connection.ts index 484edfb28..969d2e3cb 100644 --- a/packages/vitnode/src/api/models/sso-connection.ts +++ b/packages/vitnode/src/api/models/sso-connection.ts @@ -786,6 +786,10 @@ export class SsoConnectionModel { if (outcome === "not_connected") { throw new SsoConnectionError("not_connected", 409); } + + await this.c + .get("events") + .emit("user.sso.preferences_updated", { sources, sync, userId }); } private get adapters(): SSOApiPlugin[] { diff --git a/packages/vitnode/src/api/models/user-devices.ts b/packages/vitnode/src/api/models/user-devices.ts new file mode 100644 index 000000000..1a024c581 --- /dev/null +++ b/packages/vitnode/src/api/models/user-devices.ts @@ -0,0 +1,179 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { and, eq, gt, inArray } from "drizzle-orm"; + +import { core_admin_sessions } from "@/database/admins"; +import { + core_sessions, + core_sessions_known_devices, +} from "@/database/sessions"; +import { parseUserAgent } from "@/lib/api/parse-user-agent"; + +export const SESSION_KINDS = ["user", "admin"] as const; +type SessionKind = (typeof SESSION_KINDS)[number]; + +export const zodUserDeviceSchema = z.object({ + publicId: z.string(), + ipAddress: z.string(), + os: z.string(), + browser: z.string(), + deviceType: z.enum(["desktop", "tablet", "mobile"]), + lastSeen: z.date(), + expiresAt: z.date(), + sessionKinds: z.array(z.enum(SESSION_KINDS)), +}); + +export type UserDevice = z.infer; + +interface ActiveSession { + deviceId: number; + expiresAt: Date; + kind: SessionKind; +} + +interface DeviceSessions { + expiresAt: Date; + kinds: Set; +} + +const activeSessionsOf = async ( + c: Context, + userId: number, +): Promise => { + const db = c.get("db"); + const now = new Date(); + + const [userSessions, adminSessions] = await Promise.all([ + db + .select({ + deviceId: core_sessions.deviceId, + expiresAt: core_sessions.expiresAt, + }) + .from(core_sessions) + .where( + and(eq(core_sessions.userId, userId), gt(core_sessions.expiresAt, now)), + ), + db + .select({ + deviceId: core_admin_sessions.deviceId, + expiresAt: core_admin_sessions.expiresAt, + }) + .from(core_admin_sessions) + .where( + and( + eq(core_admin_sessions.userId, userId), + gt(core_admin_sessions.expiresAt, now), + ), + ), + ]); + + return [ + ...userSessions.map(session => ({ ...session, kind: "user" as const })), + ...adminSessions.map(session => ({ ...session, kind: "admin" as const })), + ]; +}; + +const groupByDevice = ( + sessions: ActiveSession[], +): Map => { + const byDevice = new Map(); + for (const { deviceId, expiresAt, kind } of sessions) { + const existing = byDevice.get(deviceId); + if (!existing) { + byDevice.set(deviceId, { expiresAt, kinds: new Set([kind]) }); + continue; + } + existing.kinds.add(kind); + if (expiresAt > existing.expiresAt) existing.expiresAt = expiresAt; + } + + return byDevice; +}; + +export const listUserDevices = async ( + c: Context, + userId: number, +): Promise => { + const sessionsByDevice = groupByDevice(await activeSessionsOf(c, userId)); + if (sessionsByDevice.size === 0) return []; + + const rows = await c + .get("db") + .select({ + id: core_sessions_known_devices.id, + publicId: core_sessions_known_devices.publicId, + ipAddress: core_sessions_known_devices.ipAddress, + userAgent: core_sessions_known_devices.userAgent, + lastSeen: core_sessions_known_devices.lastSeen, + }) + .from(core_sessions_known_devices) + .where( + inArray(core_sessions_known_devices.id, [...sessionsByDevice.keys()]), + ); + + return rows + .flatMap(({ id, userAgent, ...device }) => { + const sessions = sessionsByDevice.get(id); + if (!sessions) return []; + + return [ + { + ...device, + ...parseUserAgent(userAgent), + expiresAt: sessions.expiresAt, + sessionKinds: SESSION_KINDS.filter(kind => sessions.kinds.has(kind)), + }, + ]; + }) + .sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime()); +}; + +const hasSessionOn = async ( + c: Context, + { deviceId, userId }: { deviceId: number; userId: number }, +): Promise => { + const db = c.get("db"); + + const [userSessions, adminSessions] = await Promise.all([ + db + .select({ deviceId: core_sessions.deviceId }) + .from(core_sessions) + .where( + and( + eq(core_sessions.userId, userId), + eq(core_sessions.deviceId, deviceId), + ), + ) + .limit(1), + db + .select({ deviceId: core_admin_sessions.deviceId }) + .from(core_admin_sessions) + .where( + and( + eq(core_admin_sessions.userId, userId), + eq(core_admin_sessions.deviceId, deviceId), + ), + ) + .limit(1), + ]); + + return userSessions.length > 0 || adminSessions.length > 0; +}; + +export const findUserDeviceId = async ( + c: Context, + { publicId, userId }: { publicId: string; userId: number }, +): Promise => { + const [device] = await c + .get("db") + .select({ id: core_sessions_known_devices.id }) + .from(core_sessions_known_devices) + .where(eq(core_sessions_known_devices.publicId, publicId)); + + if (!device) return null; + + return (await hasSessionOn(c, { deviceId: device.id, userId })) + ? device.id + : null; +}; diff --git a/packages/vitnode/src/api/models/user/get-user-by-id.ts b/packages/vitnode/src/api/models/user/get-user-by-id.ts index 102087f93..c46eeb630 100644 --- a/packages/vitnode/src/api/models/user/get-user-by-id.ts +++ b/packages/vitnode/src/api/models/user/get-user-by-id.ts @@ -30,6 +30,7 @@ export const getUserById = async ({ id, c }: { c: Context; id: number }) => { roleId: core_users.roleId, birthday: core_users.birthday, language: core_users.language, + timeZone: core_users.timeZone, avatarKey: avatarFile.key, coverKey: coverFile.key, }) diff --git a/packages/vitnode/src/api/modules/admin/admin.module.ts b/packages/vitnode/src/api/modules/admin/admin.module.ts index e2bbe287f..60ebd591f 100644 --- a/packages/vitnode/src/api/modules/admin/admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/admin.module.ts @@ -6,6 +6,7 @@ import { dashboardAdminModule } from "./dashboard/dashboard.admin.module"; import { debugAdminModule } from "./debug/debug.admin.module"; import { filesAdminModule } from "./files/files.admin.module"; import { navigationAdminModule } from "./navigation/navigation.admin.module"; +import { notificationsAdminModule } from "./notifications/notifications.admin.module"; import { rolesAdminModule } from "./roles/roles.admin.module"; import { sendNotificationRoute } from "./routes/notifications.route"; import { sessionAdminRoute } from "./routes/session.route"; @@ -25,6 +26,7 @@ export const adminModule = buildModule({ filesAdminModule, dashboardAdminModule, navigationAdminModule, + notificationsAdminModule, ], cronJobs: [], }); diff --git a/packages/vitnode/src/api/modules/admin/advanced/cron/cron.admin.module.ts b/packages/vitnode/src/api/modules/admin/advanced/cron/cron.admin.module.ts index 245dba318..ef109d03f 100644 --- a/packages/vitnode/src/api/modules/admin/advanced/cron/cron.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/advanced/cron/cron.admin.module.ts @@ -2,10 +2,11 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; import { getCronsRoute } from "./routes/get.route"; +import { getCronHealthRoute } from "./routes/health.route"; import { runCronRoute } from "./routes/run.route"; export const cronAdminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "cron", - routes: [getCronsRoute, runCronRoute], + routes: [getCronsRoute, getCronHealthRoute, runCronRoute], }); diff --git a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts index 3929817de..7dfa50ef1 100644 --- a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts +++ b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts @@ -9,6 +9,7 @@ import { } from "@/api/lib/with-pagination"; import { CONFIG_PLUGIN } from "@/config"; import { core_cron } from "@/database/cron"; +import { isCronJobOverdue } from "@/lib/api/cron-health"; export const getCronsRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, @@ -21,6 +22,7 @@ export const getCronsRoute = buildRoute({ query: zodPaginationQuery.extend({ order: z.enum(["asc", "desc"]).optional(), orderBy: z.enum(["createdAt", "lastRun", "nextRun"]).optional(), + search: z.string().optional(), }), }, responses: { @@ -38,6 +40,7 @@ export const getCronsRoute = buildRoute({ module: z.string(), lastRun: z.date().nullable(), nextRun: z.date().nullable(), + overdue: z.boolean(), schedule: z.string(), }), ), @@ -57,6 +60,12 @@ export const getCronsRoute = buildRoute({ }, c, primaryCursor: core_cron.id, + search: [ + core_cron.name, + core_cron.description, + core_cron.pluginId, + core_cron.module, + ], query: async ({ cursorSelection, limit, offset, where, orderBy }) => await c .get("db") @@ -73,6 +82,14 @@ export const getCronsRoute = buildRoute({ }, }); - return c.json(data); + const now = new Date(); + + return c.json({ + ...data, + edges: data.edges.map(edge => ({ + ...edge, + overdue: isCronJobOverdue(edge, now), + })), + }); }, }); diff --git a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/health.route.ts b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/health.route.ts new file mode 100644 index 000000000..42882d218 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/health.route.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { loadCronHealth } from "@/api/modules/cron/helpers/load-cron-health"; +import { CONFIG_PLUGIN } from "@/config"; +import { isCronSecretRejected } from "@/lib/config"; + +export const getCronHealthRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "cron", permission: "can_view" }, + route: { + method: "get", + description: + "Report whether the cron scheduler is running its jobs on time", + path: "/health", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + active: z.boolean(), + jobs: z.number(), + lastRun: z.string().nullable(), + nextRun: z.string().nullable(), + overdueJobs: z.number(), + secretRejected: z.boolean(), + stale: z.boolean(), + }), + }, + }, + description: "Cron health", + }, + }, + }, + handler: async c => { + const health = await loadCronHealth(c.get("db")); + + return c.json( + { + active: c.get("core").hasCronAdapter, + jobs: health.jobs, + lastRun: health.lastRun?.toISOString() ?? null, + nextRun: health.nextRun?.toISOString() ?? null, + overdueJobs: health.overdueJobs, + secretRejected: isCronSecretRejected(c.get("core").cronSecret), + stale: health.stale, + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts index 30d8dfb14..b0e3a42b4 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts @@ -1,11 +1,10 @@ import { inArray, sql } from "drizzle-orm"; import { z } from "zod"; +import { loadCronHealth } from "@/api/modules/cron/helpers/load-cron-health"; import { CONFIG_PLUGIN } from "@/config"; -import { core_cron } from "@/database/cron"; import { core_queue } from "@/database/queue"; import { getQueueStatus } from "@/lib/api/get-queue-status"; -import { isCronStale } from "@/lib/api/is-cron-stale"; import { INSECURE_DEFAULT_CRON_SECRET } from "@/lib/config"; import { isRealtimePubSubEnabled, isWebSocketEnabled } from "@/ws/registry"; @@ -54,12 +53,10 @@ export const integrationsDebugAdminRoute = buildRoute({ // ISO timestamp of the most recent cron execution, or `null` // when no job has run yet. lastRun: z.string().nullable(), + overdueJobs: z.number(), // `false` when `CRON_SECRET` is left at its well-known default, // which leaves the cron endpoint effectively unauthenticated. secure: z.boolean(), - // `true` when no job has run within the staleness window, i.e. - // the scheduler looks misconfigured or stopped even though jobs - // are registered. stale: z.boolean(), }), email: z.object({ @@ -122,30 +119,14 @@ export const integrationsDebugAdminRoute = buildRoute({ const queueProcessing = queueGrouped.find(row => row.status === "processing")?.count ?? 0; - // Freshest cron heartbeat: a job's last successful run, or its creation - // time while it's never run so a brand-new install isn't flagged stale - // during the grace period before the first tick. - const [cronActivity] = await c - .get("db") - .select({ - lastRun: sql`max(${core_cron.lastRun})`, - lastActivity: sql`max(coalesce(${core_cron.lastRun}, ${core_cron.createdAt}))`, - }) - .from(core_cron); - - const cronLastRun = cronActivity?.lastRun - ? new Date(cronActivity.lastRun) - : null; - const cronStale = isCronStale( - cronActivity?.lastActivity ? new Date(cronActivity.lastActivity) : null, - ); + const cronHealth = await loadCronHealth(c.get("db")); const cronActive = core.hasCronAdapter; const previewContentTypes = core.contentTypes.filter( entry => entry.definition.editorial.preview.enabled, ).length; const queueStatus = getQueueStatus({ cronActive, - cronStale, + cronStale: cronHealth.stale, hasTaskHandlers: core.queue.length > 0, }); @@ -169,11 +150,12 @@ export const integrationsDebugAdminRoute = buildRoute({ cron: { active: cronActive, jobs: core.cron.length, - lastRun: cronLastRun ? cronLastRun.toISOString() : null, + lastRun: cronHealth.lastRun?.toISOString() ?? null, + overdueJobs: cronHealth.overdueJobs, secure: !!core.cronSecret && core.cronSecret !== INSECURE_DEFAULT_CRON_SECRET, - stale: cronStale, + stale: cronHealth.stale, }, email: { active: !!core.email?.adapter, diff --git a/packages/vitnode/src/api/modules/admin/notifications/notifications.admin.module.ts b/packages/vitnode/src/api/modules/admin/notifications/notifications.admin.module.ts new file mode 100644 index 000000000..2ad9600bf --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/notifications.admin.module.ts @@ -0,0 +1,42 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + cancelQueuedNotificationEmailsRoute, + deleteAllNotificationsRoute, + markEverythingReadRoute, + pauseNotificationsRoute, + resetMemberNotificationPreferencesRoute, + resumeNotificationsRoute, +} from "./routes/danger.route"; +import { + listNotificationDeliveriesRoute, + retryNotificationDeliveryRoute, +} from "./routes/deliveries.route"; +import { + reconcileNotificationCountsRoute, + sendNotificationTestEmailRoute, +} from "./routes/maintenance.route"; +import { getNotificationsOverviewRoute } from "./routes/overview.route"; +import { updateNotificationTypePolicyRoute } from "./routes/settings.route"; +import { getNotificationStatsRoute } from "./routes/stats.route"; + +export const notificationsAdminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "notifications", + routes: [ + getNotificationsOverviewRoute, + updateNotificationTypePolicyRoute, + listNotificationDeliveriesRoute, + retryNotificationDeliveryRoute, + sendNotificationTestEmailRoute, + reconcileNotificationCountsRoute, + getNotificationStatsRoute, + pauseNotificationsRoute, + resumeNotificationsRoute, + cancelQueuedNotificationEmailsRoute, + markEverythingReadRoute, + deleteAllNotificationsRoute, + resetMemberNotificationPreferencesRoute, + ], +}); diff --git a/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.test.ts b/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.test.ts new file mode 100644 index 000000000..46de11c45 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { afterAll, beforeAll, expect, it } from "vitest"; +import { z } from "zod"; + +import { buildNotificationType } from "@/api/lib/notifications/registry"; +import { createTestCache } from "@/tests/cache"; +import { + createNotificationsHarness, + type NotificationsHarness, +} from "@/tests/notifications"; +import { describePostgres } from "@/tests/postgres"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { + deleteAllNotificationsRoute, + resetMemberNotificationPreferencesRoute, +} from "./danger.route"; +import { getNotificationStatsRoute } from "./stats.route"; + +const pingType = buildNotificationType({ + id: "test.ping", + version: 1, + schema: z.object({}), + category: "system", + label: "test.ping.label", + defaults: { email: "none", inApp: true }, + present: () => ({ title: "Ping" }), +}); + +const VIEWER = { email: "viewer@example.com", id: 1 }; +const MANAGER = { email: "manager@example.com", id: 2 }; +const PLUGIN = "@vitnode/core"; + +const SHARED_KEYS = [ + "core", + "db", + "email", + "events", + "i18n", + "log", + "notifications", + "queue", + "realtime", +]; + +describePostgres("AdminCP notification danger routes", () => { + let h: NotificationsHarness; + const cache = createTestCache(); + + beforeAll(async () => { + h = await createNotificationsHarness({ types: [pingType] }); + await grantStaffPermissions(cache, { + permissions: [ + { module: "notifications", permission: "can_view", plugin: PLUGIN }, + ], + userId: VIEWER.id, + }); + await grantStaffPermissions(cache, { + permissions: [ + { module: "notifications", permission: "can_view", plugin: PLUGIN }, + { module: "notifications", permission: "can_manage", plugin: PLUGIN }, + ], + userId: MANAGER.id, + }); + }); + + afterAll(async () => { + await h?.database.drop(); + }); + + const appFor = (admin: typeof VIEWER) => { + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + for (const key of SHARED_KEYS) { + c.set(key as never, h.c.get(key as never)); + } + c.set("cache", cache); + c.set("admin", { user: admin } as unknown as Context["var"]["admin"]); + await next(); + }); + for (const route of [ + deleteAllNotificationsRoute, + resetMemberNotificationPreferencesRoute, + getNotificationStatsRoute, + ]) { + app.openapi(route.route, route.handler); + } + + return app; + }; + + it("refuses destructive actions to staff without the manage permission", async () => { + const app = appFor(VIEWER); + + expect((await app.request("/delete-all", { method: "POST" })).status).toBe( + 403, + ); + expect( + (await app.request("/members/reset-preferences", { method: "POST" })) + .status, + ).toBe(403); + }); + + it("runs them for staff who may manage notifications", async () => { + const app = appFor(MANAGER); + + const response = await app.request("/delete-all", { method: "POST" }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ events: 0, items: 0 }); + }); + + it("serves activity to staff who may view notifications, and rejects unknown time zones", async () => { + const app = appFor(VIEWER); + + const ok = await app.request("/stats?range=7d&timeZone=Europe%2FWarsaw"); + const bad = await app.request("/stats?range=7d&timeZone=Mars%2FOlympus"); + + expect(ok.status).toBe(200); + expect((await ok.json()).points).toHaveLength(7); + expect(bad.status).toBe(400); + }); +}); 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 new file mode 100644 index 000000000..bc3c593d1 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { + cancelQueuedNotificationEmails, + deleteAllNotifications, + markEverythingRead, + pauseNotifications, + resetMemberNotificationPreferences, + resumeNotifications, +} from "@/api/models/notifications/danger"; +import { CONFIG_PLUGIN } from "@/config"; + +const canManageNotifications = { + module: "notifications", + permission: "can_manage", +} as const; + +const dangerResponses = (schema: T, description: string) => + ({ + 200: { content: { "application/json": { schema } }, description }, + 403: { description: "Access Denied" }, + }) as const; + +export const pauseNotificationsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: canManageNotifications, + route: { + method: "post", + description: + "Stop fanning out and sending notifications. New events are still stored and wait until notifications resume.", + path: "/pause", + responses: dangerResponses(z.object({ success: z.boolean() }), "Paused"), + }, + handler: async c => { + await pauseNotifications(c); + + return c.json({ success: true }); + }, +}); + +export const resumeNotificationsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: canManageNotifications, + route: { + method: "post", + description: + "Resume notifications and queue every event that waited while they were paused.", + path: "/resume", + responses: dangerResponses( + z.object({ requeuedEvents: z.number() }), + "Resumed", + ), + }, + handler: async c => c.json(await resumeNotifications(c)), +}); + +export const cancelQueuedNotificationEmailsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: canManageNotifications, + route: { + method: "post", + description: + "Skip every email that has not gone out yet, including the next digests. In-app notifications stay.", + path: "/emails/cancel", + responses: dangerResponses( + z.object({ cancelled: z.number() }), + "Cancelled", + ), + }, + handler: async c => c.json(await cancelQueuedNotificationEmails(c)), +}); + +export const markEverythingReadRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: canManageNotifications, + route: { + method: "post", + description: + "Mark every unread notification as read for every member and zero their badges.", + path: "/read-all", + responses: dangerResponses( + z.object({ items: z.number(), members: z.number() }), + "Marked as read", + ), + }, + handler: async c => c.json(await markEverythingRead(c)), +}); + +export const deleteAllNotificationsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: canManageNotifications, + route: { + method: "post", + description: + "Delete every member's notifications. Types, preferences, settings and email history are kept.", + path: "/delete-all", + responses: dangerResponses( + z.object({ events: z.number(), items: z.number() }), + "Deleted", + ), + }, + handler: async c => c.json(await deleteAllNotifications(c)), +}); + +export const resetMemberNotificationPreferencesRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: canManageNotifications, + route: { + method: "post", + description: + "Drop every member's own notification choices and digest schedule so they follow the installation defaults.", + path: "/members/reset-preferences", + responses: dangerResponses(z.object({ members: z.number() }), "Reset"), + }, + handler: async c => c.json(await resetMemberNotificationPreferences(c)), +}); diff --git a/packages/vitnode/src/api/modules/admin/notifications/routes/deliveries.route.ts b/packages/vitnode/src/api/modules/admin/notifications/routes/deliveries.route.ts new file mode 100644 index 000000000..68c824e2e --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/deliveries.route.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { + listNotificationDeliveries, + retryNotificationDelivery, +} from "@/api/models/notifications/admin"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_notification_deliveries } from "@/database/notifications"; + +const zodDeliveryStatus = z.enum( + core_notification_deliveries.status.enumValues, +); + +export const listNotificationDeliveriesRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "notifications", permission: "can_view" }, + route: { + method: "get", + description: + "Email delivery records, newest first. Recipients appear as user ids; addresses and content are never returned.", + path: "/deliveries", + request: { + query: z.object({ + cursor: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), + status: zodDeliveryStatus.optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + items: z.array( + z.object({ + attempts: z.number(), + availableAt: z.date(), + createdAt: z.date(), + id: z.number(), + itemCount: z.number(), + lastError: z.string().nullable(), + maxAttempts: z.number(), + mode: z.enum(core_notification_deliveries.mode.enumValues), + providerMessageId: z.string().nullable(), + sentAt: z.date().nullable(), + skipReason: z.string().nullable(), + status: zodDeliveryStatus, + type: z.string().nullable(), + updatedAt: z.date(), + userId: z.number(), + }), + ), + nextCursor: z.number().nullable(), + }), + }, + }, + description: "Delivery records", + }, + 403: { description: "Access Denied" }, + }, + }, + handler: async c => { + const { cursor, limit, status } = c.req.valid("query"); + + return c.json( + await listNotificationDeliveries(c, { beforeId: cursor, limit, status }), + ); + }, +}); + +export const retryNotificationDeliveryRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "notifications", permission: "can_manage" }, + route: { + method: "post", + description: + "Retry a failed email delivery with the same idempotency key and the same notifications.", + path: "/deliveries/{id}/retry", + request: { params: z.object({ id: z.coerce.number().int().positive() }) }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ success: z.boolean() }) }, + }, + description: "Queued for retry", + }, + 403: { description: "Access Denied" }, + 409: { description: "The delivery has not failed" }, + }, + }, + handler: async c => { + await retryNotificationDelivery(c, c.req.valid("param").id); + + return c.json({ success: true }); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/notifications/routes/maintenance.route.ts b/packages/vitnode/src/api/modules/admin/notifications/routes/maintenance.route.ts new file mode 100644 index 000000000..0b1204415 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/maintenance.route.ts @@ -0,0 +1,75 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { + queueNotificationTestEmail, + reconcileNotifications, +} from "@/api/models/notifications/admin"; +import { CONFIG_PLUGIN } from "@/config"; + +export const sendNotificationTestEmailRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "notifications", permission: "can_manage" }, + route: { + method: "post", + description: + "Queue a test notification email to the signed-in administrator. It cannot be sent to anyone else.", + path: "/test-email", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ deliveryId: z.number() }), + }, + }, + description: "Queued", + }, + 400: { description: "Notification email is not configured" }, + 403: { description: "Access Denied" }, + }, + }, + handler: async c => { + const admin = c.get("admin")?.user; + if (!admin) throw new HTTPException(403); + + return c.json(await queueNotificationTestEmail(c, admin.id)); + }, +}); + +export const reconcileNotificationCountsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "notifications", permission: "can_manage" }, + route: { + method: "post", + description: + "Compare stored unread counts with the inbox and, unless dryRun, fix the ones that drifted.", + path: "/reconcile", + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + dryRun: z.boolean().default(true), + userId: z.number().int().positive().optional(), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ corrected: z.number(), mismatched: z.number() }), + }, + }, + description: "Reconciliation result", + }, + 403: { description: "Access Denied" }, + }, + }, + handler: async c => + c.json(await reconcileNotifications(c, c.req.valid("json"))), +}); diff --git a/packages/vitnode/src/api/modules/admin/notifications/routes/overview.route.ts b/packages/vitnode/src/api/modules/admin/notifications/routes/overview.route.ts new file mode 100644 index 000000000..e26b8b365 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/overview.route.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { getNotificationsOverview } from "@/api/models/notifications/admin"; +import { zodNotificationEmailMode } from "@/api/modules/notifications/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +const zodCounts = z.record(z.string(), z.number()); + +export const getNotificationsOverviewRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "notifications", permission: "can_view" }, + route: { + method: "get", + description: + "Registered notification types, installation settings and delivery health. Aggregates only - no content or recipients.", + path: "/overview", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + counts: z.object({ + customizedMembers: z.number(), + inboxItems: z.number(), + queuedEmails: z.number(), + unreadItems: z.number(), + }), + email: z.object({ + adapterConfigured: z.boolean(), + }), + health: z.object({ + cronActive: z.boolean(), + cronStale: z.boolean(), + deliveries: zodCounts, + events: zodCounts, + oldestPendingEventAt: z.date().nullable(), + queue: zodCounts, + }), + settings: z.object({ paused: z.boolean() }), + workers: z.object({ + emailBatchSize: z.number(), + emailConcurrency: z.number(), + fanoutBatchSize: z.number(), + retentionDays: z.number(), + }), + types: z.array( + z.object({ + category: z.string(), + defaults: z.object({ + email: zodNotificationEmailMode, + inApp: z.boolean(), + }), + emailAvailable: z.boolean(), + emailSupported: z.boolean(), + grouped: z.boolean(), + id: z.string(), + label: z.string(), + mandatory: z.boolean(), + pluginId: z.string(), + policy: z.object({ + allowEmail: z.boolean(), + allowInApp: z.boolean(), + allowPush: z.boolean(), + email: zodNotificationEmailMode.nullable(), + enabled: z.boolean(), + inApp: z.boolean().nullable(), + memberCanEdit: z.boolean(), + }), + version: z.number(), + }), + ), + }), + }, + }, + description: "Notifications overview", + }, + 403: { description: "Access Denied" }, + }, + }, + handler: async c => c.json(await getNotificationsOverview(c)), +}); diff --git a/packages/vitnode/src/api/modules/admin/notifications/routes/settings.route.ts b/packages/vitnode/src/api/modules/admin/notifications/routes/settings.route.ts new file mode 100644 index 000000000..4139c2047 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/settings.route.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { updateNotificationTypePolicy } from "@/api/models/notifications/admin"; +import { zodNotificationEmailMode } from "@/api/modules/notifications/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +export const updateNotificationTypePolicyRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "notifications", permission: "can_edit" }, + route: { + method: "put", + description: + "Set the installation policy for one type: which channels it may use, the defaults for members who never chose, and whether members may change them.", + path: "/types/{type}", + request: { + params: z.object({ type: z.string().max(100) }), + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + allowEmail: z.boolean().optional(), + allowInApp: z.boolean().optional(), + allowPush: z.boolean().optional(), + email: zodNotificationEmailMode.optional(), + enabled: z.boolean().optional(), + inApp: z.boolean().optional(), + memberCanEdit: z.boolean().optional(), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ success: z.boolean() }) }, + }, + description: "Saved", + }, + 400: { description: "Email for a type without an email channel" }, + 403: { description: "Access Denied" }, + 404: { description: "Unknown type" }, + }, + }, + handler: async c => { + const { type } = c.req.valid("param"); + await updateNotificationTypePolicy(c, type, c.req.valid("json")); + + return c.json({ success: true }); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/notifications/routes/stats.route.ts b/packages/vitnode/src/api/modules/admin/notifications/routes/stats.route.ts new file mode 100644 index 000000000..be886536d --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/stats.route.ts @@ -0,0 +1,61 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { isValidTimeZone } from "@/api/lib/notifications/digest-period"; +import { buildRoute } from "@/api/lib/route"; +import { + getNotificationStats, + NOTIFICATION_STATS_RANGES, +} from "@/api/models/notifications/stats"; +import { CONFIG_PLUGIN } from "@/config"; + +const zodTotals = z.object({ + events: z.number(), + failed: z.number(), + sent: z.number(), + skipped: z.number(), +}); + +export const getNotificationStatsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "notifications", permission: "can_view" }, + route: { + method: "get", + description: + "Published events and finished emails per hour or day for the chosen range, bucketed in the viewer's time zone, with the totals of the period before it.", + path: "/stats", + request: { + query: z.object({ + range: z.enum(NOTIFICATION_STATS_RANGES).default("7d"), + timeZone: z.string().max(64).default("UTC"), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + points: z.array(zodTotals.extend({ key: z.string() })), + previous: zodTotals, + range: z.enum(NOTIFICATION_STATS_RANGES), + timeZone: z.string(), + totals: zodTotals, + unit: z.enum(["day", "hour"]), + }), + }, + }, + description: "Notification activity", + }, + 400: { description: "Unknown time zone" }, + 403: { description: "Access Denied" }, + }, + }, + handler: async c => { + const { range, timeZone } = c.req.valid("query"); + if (!isValidTimeZone(timeZone)) { + throw new HTTPException(400, { message: "Unknown time zone." }); + } + + return c.json(await getNotificationStats(c, { range, timeZone })); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/routes/notifications.route.ts b/packages/vitnode/src/api/modules/admin/routes/notifications.route.ts index f1b32ee6b..852bf711e 100644 --- a/packages/vitnode/src/api/modules/admin/routes/notifications.route.ts +++ b/packages/vitnode/src/api/modules/admin/routes/notifications.route.ts @@ -1,6 +1,7 @@ import { HTTPException } from "hono/http-exception"; import { z } from "zod"; +import { adminMessageNotification } from "@/api/lib/notifications/core-types"; import { buildRoute } from "@/api/lib/route"; import { CONFIG_PLUGIN } from "@/config"; import { notificationsChannel } from "@/ws/notifications"; @@ -23,7 +24,8 @@ export const sendNotificationRoute = buildRoute({ adminStaffPermission: { module: "dashboard", permission: "can_edit" }, route: { method: "post", - description: "Send a notification to a user", + description: + "Send a notification to a user: a toast on their open tabs and an entry in their inbox", path: "/notifications/send", request: { body: { @@ -48,7 +50,7 @@ export const sendNotificationRoute = buildRoute({ }, }, }, - handler: c => { + handler: async c => { const admin = c.get("admin")?.user; if (!admin) throw new HTTPException(403); @@ -61,6 +63,15 @@ export const sendNotificationRoute = buildRoute({ type, }); + await c.get("notifications").publish({ + type: adminMessageNotification, + actorId: admin.id, + allowSelf: true, + recipients: [userId], + data: { description, title }, + idempotencyKey: `admin-message:${crypto.randomUUID()}`, + }); + return c.json({ success: true }); }, }); diff --git a/packages/vitnode/src/api/modules/admin/users/lib/target-user.ts b/packages/vitnode/src/api/modules/admin/users/lib/target-user.ts new file mode 100644 index 000000000..37b77268a --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/lib/target-user.ts @@ -0,0 +1,47 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; + +import { core_users } from "@/database/users"; + +const MAX_USER_ID = 2_147_483_647; + +export const zodTargetUserIdParam = z.string().openapi({ example: "1" }); + +export const zodTargetUserParams = z.object({ id: zodTargetUserIdParam }); + +export const userNotFoundResponse = { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "User not found", +}; + +export const USER_NOT_FOUND = { error: "User not found" }; + +const parseUserId = (id: string): null | number => { + if (!/^[1-9]\d{0,9}$/.test(id)) return null; + const userId = Number(id); + + return userId <= MAX_USER_ID ? userId : null; +}; + +export const findTargetUserId = async ( + c: Context, + id: string, +): Promise => { + const userId = parseUserId(id); + if (userId === null) return null; + + const [user] = await c + .get("db") + .select({ id: core_users.id }) + .from(core_users) + .where(eq(core_users.id, userId)) + .limit(1); + + return user?.id ?? null; +}; diff --git a/packages/vitnode/src/api/modules/admin/users/routes/devices.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/devices.route.test.ts new file mode 100644 index 000000000..cd86e606c --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/devices.route.test.ts @@ -0,0 +1,344 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; + +import { buildModule } from "@/api/lib/module"; +import { + adminSessionCacheKey, + sessionCacheKey, +} from "@/api/models/session-cache"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_admin_permissions, core_admin_sessions } from "@/database/admins"; +import { core_moderators_permissions } from "@/database/moderators"; +import { core_roles } from "@/database/roles"; +import { + core_sessions, + core_sessions_known_devices, +} from "@/database/sessions"; +import { core_users, core_users_secondary_roles } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { listUserDevicesAdminRoute } from "./devices.route"; +import { revokeUserDeviceAdminRoute } from "./revoke-device.route"; +import { revokeUserDevicesAdminRoute } from "./revoke-devices.route"; + +const MEMBER_ROLE = 3; +const EDITOR = { email: "editor@example.com", id: 1, roleId: MEMBER_ROLE }; +const TARGET_ID = 7; +const OTHER_ID = 8; +const MODERATOR_ID = 9; + +const permission = (name: string): PermissionsStaffArgs => ({ + module: "users", + permission: name, + plugin: "@vitnode/core", +}); + +const CAN_VIEW = permission("can_view"); +const CAN_EDIT = permission("can_edit"); +const CAN_EDIT_ADMIN = permission("can_edit_admin"); + +const PHONE = { id: 4, lastSeen: new Date("2026-09-30"), publicId: "phone" }; +const OFFICE = { id: 5, lastSeen: new Date("2026-09-20"), publicId: "office" }; +const LAPTOP = { id: 3, lastSeen: new Date("2026-09-10"), publicId: "laptop" }; +const TABLET = { id: 6, lastSeen: new Date("2026-09-25"), publicId: "tablet" }; + +const userRow = (id: number) => ({ + email: `${id}@example.com`, + id, + name: `user-${id}`, + nameCode: `user-${id}`, + roleId: MEMBER_ROLE, +}); + +const harness = async (editor: PermissionsStaffArgs[]) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: editor, + userId: EDITOR.id, + }); + + const expiresAt = new Date(Date.now() + 1000 * 60 * 30); + const expired = new Date(Date.now() - 1000); + const session = (userId: number, deviceId: number, at = expiresAt) => ({ + deviceId, + expiresAt: at, + token: `token-${userId}-${deviceId}`, + userId, + }); + + const memory = createMemoryDb([ + [ + core_users, + [EDITOR, userRow(TARGET_ID), userRow(OTHER_ID), userRow(MODERATOR_ID)], + ], + [core_users_secondary_roles, []], + [core_roles, [{ guest: false, id: MEMBER_ROLE, root: false }]], + [core_admin_permissions, []], + [ + core_moderators_permissions, + [ + { + permissions: [], + roleId: null, + unrestricted: false, + userId: MODERATOR_ID, + }, + ], + ], + [ + core_sessions_known_devices, + [PHONE, OFFICE, LAPTOP, TABLET].map(device => ({ + ipAddress: "203.0.113.7", + userAgent: "node", + ...device, + })), + ], + [ + core_sessions, + [ + session(TARGET_ID, PHONE.id), + session(TARGET_ID, LAPTOP.id), + session(TARGET_ID, TABLET.id, expired), + session(OTHER_ID, TABLET.id), + session(MODERATOR_ID, PHONE.id), + ], + ], + [ + core_admin_sessions, + [session(TARGET_ID, PHONE.id), session(TARGET_ID, OFFICE.id)], + ], + ]); + + await cache.setSystem( + sessionCacheKey(`token-${TARGET_ID}-${PHONE.id}`, PHONE.id), + { + cached: true, + }, + ); + await cache.setSystem( + adminSessionCacheKey(`token-${TARGET_ID}-${OFFICE.id}`, OFFICE.id), + { cached: true }, + ); + + const emit = vi.fn(async () => Promise.resolve(undefined)); + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: EDITOR } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + await next(); + }); + app.route( + "/", + buildModule({ + name: "users", + pluginId: CONFIG_PLUGIN.pluginId, + routes: [ + listUserDevicesAdminRoute, + revokeUserDevicesAdminRoute, + revokeUserDeviceAdminRoute, + ], + }).hono, + ); + + const sessionsOf = (userId: number) => ({ + admin: memory + .rows(core_admin_sessions) + .filter(row => row.userId === userId) + .map(row => row.deviceId), + user: memory + .rows(core_sessions) + .filter(row => row.userId === userId) + .map(row => row.deviceId), + }); + + const isCached = async () => ({ + admin: + (await cache.getSystem( + adminSessionCacheKey(`token-${TARGET_ID}-${OFFICE.id}`, OFFICE.id), + )) !== null, + user: + (await cache.getSystem( + sessionCacheKey(`token-${TARGET_ID}-${PHONE.id}`, PHONE.id), + )) !== null, + }); + + const request = async (path: string, method = "GET") => + await app.request(path, { method }); + + return { emit, isCached, request, sessionsOf }; +}; + +describe("GET /admin/users/{id}/devices", () => { + it("lists only the user's active devices, newest first, with session kinds", async () => { + const h = await harness([CAN_VIEW]); + + const response = await h.request(`/${TARGET_ID}/devices`); + + expect(response.status).toBe(200); + const { devices } = (await response.json()) as { + devices: Record[]; + }; + expect( + devices.map(({ publicId, sessionKinds }) => ({ publicId, sessionKinds })), + ).toEqual([ + { publicId: PHONE.publicId, sessionKinds: ["user", "admin"] }, + { publicId: OFFICE.publicId, sessionKinds: ["admin"] }, + { publicId: LAPTOP.publicId, sessionKinds: ["user"] }, + ]); + expect(devices[0]).not.toHaveProperty("isCurrent"); + }); + + it("returns 404 for an unknown user", async () => { + const h = await harness([CAN_VIEW]); + + const response = await h.request("/404/devices"); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "User not found" }); + }); + + it("refuses a caller without users:can_view", async () => { + const h = await harness([CAN_EDIT]); + + expect((await h.request(`/${TARGET_ID}/devices`)).status).toBe(403); + }); +}); + +describe("DELETE /admin/users/{id}/devices/{publicId}", () => { + it("ends both session kinds on that device and drops their cache", async () => { + const h = await harness([CAN_EDIT]); + + const response = await h.request( + `/${TARGET_ID}/devices/${PHONE.publicId}`, + "DELETE", + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(h.sessionsOf(TARGET_ID)).toEqual({ + admin: [OFFICE.id], + user: [LAPTOP.id, TABLET.id], + }); + expect(h.sessionsOf(MODERATOR_ID).user).toEqual([PHONE.id]); + expect((await h.isCached()).user).toBe(false); + expect(h.emit).toHaveBeenCalledWith("user.sessions.revoked", { + deviceId: PHONE.id, + sessions: 2, + userId: TARGET_ID, + }); + }); + + it("returns 404 for a device the user has no session on", async () => { + const h = await harness([CAN_EDIT]); + + const response = await h.request(`/${OTHER_ID}/devices/phone`, "DELETE"); + + expect(response.status).toBe(404); + expect(h.sessionsOf(MODERATOR_ID).user).toEqual([PHONE.id]); + }); + + it("returns 404 for an unknown device", async () => { + const h = await harness([CAN_EDIT]); + + expect( + (await h.request(`/${TARGET_ID}/devices/missing`, "DELETE")).status, + ).toBe(404); + }); + + it("rejects a malformed device id", async () => { + const h = await harness([CAN_EDIT]); + + expect( + (await h.request(`/${TARGET_ID}/devices/bad%20id`, "DELETE")).status, + ).toBe(400); + }); + + it("refuses a caller without users:can_edit", async () => { + const h = await harness([CAN_VIEW]); + + const response = await h.request( + `/${TARGET_ID}/devices/${PHONE.publicId}`, + "DELETE", + ); + + expect(response.status).toBe(403); + expect(h.sessionsOf(TARGET_ID).user).toContain(PHONE.id); + expect(h.emit).not.toHaveBeenCalled(); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness([CAN_EDIT]); + + const response = await h.request( + `/${MODERATOR_ID}/devices/${PHONE.publicId}`, + "DELETE", + ); + + expect(response.status).toBe(403); + expect(h.sessionsOf(MODERATOR_ID).user).toEqual([PHONE.id]); + }); + + it("lets users:can_edit_admin revoke a staff target's device", async () => { + const h = await harness([CAN_EDIT, CAN_EDIT_ADMIN]); + + const response = await h.request( + `/${MODERATOR_ID}/devices/${PHONE.publicId}`, + "DELETE", + ); + + expect(response.status).toBe(200); + expect(h.sessionsOf(MODERATOR_ID).user).toEqual([]); + }); +}); + +describe("DELETE /admin/users/{id}/devices", () => { + it("ends every session of the user and only that user", async () => { + const h = await harness([CAN_EDIT]); + + const response = await h.request(`/${TARGET_ID}/devices`, "DELETE"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(h.sessionsOf(TARGET_ID)).toEqual({ admin: [], user: [] }); + expect(h.sessionsOf(OTHER_ID).user).toEqual([TABLET.id]); + expect(await h.isCached()).toEqual({ admin: false, user: false }); + expect(h.emit).toHaveBeenCalledWith("user.sessions.revoked", { + deviceId: null, + sessions: 5, + userId: TARGET_ID, + }); + }); + + it("returns 404 for an unknown user", async () => { + const h = await harness([CAN_EDIT]); + + expect((await h.request("/404/devices", "DELETE")).status).toBe(404); + }); + + it("refuses a caller without users:can_edit", async () => { + const h = await harness([CAN_VIEW]); + + expect((await h.request(`/${TARGET_ID}/devices`, "DELETE")).status).toBe( + 403, + ); + expect(h.sessionsOf(TARGET_ID).admin).toHaveLength(2); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness([CAN_EDIT]); + + expect((await h.request(`/${MODERATOR_ID}/devices`, "DELETE")).status).toBe( + 403, + ); + expect(h.sessionsOf(MODERATOR_ID).user).toEqual([PHONE.id]); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/devices.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/devices.route.ts new file mode 100644 index 000000000..78084dd8c --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/devices.route.ts @@ -0,0 +1,50 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { + listUserDevices, + zodUserDeviceSchema, +} from "@/api/models/user-devices"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + findTargetUserId, + USER_NOT_FOUND, + userNotFoundResponse, + zodTargetUserIdParam, +} from "../lib/target-user"; + +export const listUserDevicesAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_view" }, + route: { + method: "get", + description: + "List the devices a user is signed in on, with a session of either kind (Admin only)", + path: "/{id}/devices", + request: { + params: z.object({ id: zodTargetUserIdParam }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ devices: z.array(zodUserDeviceSchema) }), + }, + }, + description: "The user's devices, most recently seen first", + }, + 403: { + description: "Access Denied", + }, + 404: userNotFoundResponse, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const userId = await findTargetUserId(c, id); + if (userId === null) return c.json(USER_NOT_FOUND, 404); + + return c.json({ devices: await listUserDevices(c, userId) }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences-update.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences-update.route.ts new file mode 100644 index 000000000..74ad19240 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences-update.route.ts @@ -0,0 +1,70 @@ +import { buildRoute } from "@/api/lib/route"; +import { + getNotificationPreferences, + updateNotificationPreferences, +} from "@/api/models/notifications/preferences"; +import { + zodNotificationPreferences, + zodUpdateNotificationPreferences, +} from "@/api/modules/notifications/routes/preferences.route"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + userNotFoundResponse, + zodTargetUserParams, +} from "../lib/target-user"; + +export const updateUserNotificationPreferencesAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "put", + description: + "Save a user's notification preferences. Staff may change types the installation locks for members, but never mandatory ones (Admin only)", + path: "/{id}/notification-preferences", + request: { + params: zodTargetUserParams, + body: { + required: true, + content: { + "application/json": { schema: zodUpdateNotificationPreferences }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: zodNotificationPreferences }, + }, + description: "Saved, with the preferences as they are now", + }, + 400: { + description: + "An unknown or mandatory type, or a channel the type does not offer", + }, + 403: { + description: "Access Denied", + }, + 404: userNotFoundResponse, + }, + }, + handler: async c => { + const userId = await findTargetUserId(c, c.req.valid("param").id); + if (userId === null) { + return c.json({ error: "User not found" }, 404); + } + + await assertCanEditAdminTarget(c, userId); + await updateNotificationPreferences(c, userId, c.req.valid("json")); + + return c.json( + await getNotificationPreferences(c, { + language: c.get("admin")?.user.language, + userId, + }), + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.test.ts new file mode 100644 index 000000000..ce8c0fde4 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.test.ts @@ -0,0 +1,345 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import type { NotificationTypePolicy } from "@/api/lib/notifications/preferences"; +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; +import type { NotificationTypePreference } from "@/database/notifications"; + +import { + buildNotificationType, + createNotificationRegistry, +} from "@/api/lib/notifications/registry"; +import { core_admin_permissions } from "@/database/admins"; +import { core_moderators_permissions } from "@/database/moderators"; +import { + core_notification_settings, + core_notification_user_state, +} from "@/database/notifications"; +import { core_roles } from "@/database/roles"; +import { core_users, core_users_secondary_roles } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { updateUserNotificationPreferencesAdminRoute } from "./notification-preferences-update.route"; +import { listUserNotificationPreferencesAdminRoute } from "./notification-preferences.route"; + +const STAFF_ROLE = 2; +const MEMBER_ROLE = 3; +const EDITOR = { + email: "editor@example.com", + id: 1, + language: "en", + roleId: STAFF_ROLE, +}; +const TARGET_ID = 7; + +const permission = (name: string): PermissionsStaffArgs => ({ + module: "users", + permission: name, + plugin: "@vitnode/core", +}); + +const CAN_VIEW = permission("can_view"); +const CAN_EDIT = permission("can_edit"); + +const notificationType = ( + id: string, + overrides: { email?: boolean; mandatory?: boolean } = {}, +) => ({ + ...buildNotificationType({ + category: "social", + defaults: { + email: overrides.email === false ? "none" : "daily", + inApp: true, + }, + email: overrides.email ?? true, + id, + label: id, + present: () => ({ title: "t" }), + schema: z.object({}), + version: 1, + }), + mandatory: overrides.mandatory, +}); + +const COMMENT = "blog.comment"; +const LOCKED = "blog.digest"; +const SECURITY = "security.alert"; +const NO_EMAIL = "blog.like"; + +const translator = Object.assign((key: string) => key, { + has: () => false, +}); + +const harness = async ({ + editor = [CAN_VIEW, CAN_EDIT], + policies = { [LOCKED]: { memberCanEdit: false } }, + states = [], + targetIsStaff = false, +}: { + editor?: PermissionsStaffArgs[]; + policies?: Record; + states?: { + preferences: Record; + userId: number; + }[]; + targetIsStaff?: boolean; +} = {}) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: editor, + userId: EDITOR.id, + }); + if (targetIsStaff) { + await grantStaffPermissions(cache, { + permissions: [], + type: "moderator", + userId: TARGET_ID, + }); + } + + const memory = createMemoryDb([ + [ + core_users, + [ + EDITOR, + { + email: "target@example.com", + id: TARGET_ID, + name: "Target", + nameCode: "target", + roleId: MEMBER_ROLE, + }, + ], + ], + [core_users_secondary_roles, []], + [ + core_roles, + [ + { guest: false, id: STAFF_ROLE, root: false }, + { guest: false, id: MEMBER_ROLE, root: false }, + ], + ], + [core_admin_permissions, []], + [core_moderators_permissions, []], + [ + core_notification_settings, + Object.entries(policies).map(([typeId, value]) => ({ + key: `type:${typeId}`, + value, + })), + ], + [core_notification_user_state, states], + ]); + + const registry = createNotificationRegistry( + [ + notificationType(COMMENT), + notificationType(LOCKED), + notificationType(SECURITY, { mandatory: true }), + notificationType(NO_EMAIL, { email: false }), + ].map(definition => ({ definition, pluginId: "@acme/blog" })), + ); + + const emit = vi.fn(async () => Promise.resolve(undefined)); + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: EDITOR } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("core", { + email: { adapter: {} }, + notifications: registry, + } as unknown as EnvVariablesVitNode["core"]); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + c.set("i18n", { + getTranslator: async () => await Promise.resolve(translator), + resolveSupportedLocale: () => "en", + } as unknown as Context["var"]["i18n"]); + await next(); + }); + for (const { handler, route } of [ + listUserNotificationPreferencesAdminRoute, + updateUserNotificationPreferencesAdminRoute, + ]) { + app.openapi(route, handler); + } + + const put = async (types: Record) => + await app.request(`/${TARGET_ID}/notification-preferences`, { + body: JSON.stringify({ types }), + headers: { "content-type": "application/json" }, + method: "PUT", + }); + + const preferencesOf = (userId: number) => + memory.rows(core_notification_user_state).find(row => row.userId === userId) + ?.preferences; + + return { emit, preferencesOf, put, request: app.request.bind(app) }; +}; + +interface PreferencesBody { + types: { + id: string; + locked: boolean; + mandatory: boolean; + value: { email: string; inApp: boolean; push: boolean }; + }[]; +} + +const typeOf = (body: PreferencesBody, id: string) => + body.types.find(type => type.id === id); + +describe("GET /admin/users/{id}/notification-preferences", () => { + it("returns the target user's choices, not the admin's", async () => { + const h = await harness({ + states: [ + { preferences: { [COMMENT]: { email: "none" } }, userId: EDITOR.id }, + { + preferences: { [COMMENT]: { email: "weekly", inApp: false } }, + userId: TARGET_ID, + }, + ], + }); + + const response = await h.request(`/${TARGET_ID}/notification-preferences`); + + expect(response.status).toBe(200); + const body = (await response.json()) as PreferencesBody; + expect(typeOf(body, COMMENT)?.value).toEqual({ + email: "weekly", + inApp: false, + push: true, + }); + }); + + it("locks mandatory types and types the installation locks for members", async () => { + const h = await harness(); + + const body = (await ( + await h.request(`/${TARGET_ID}/notification-preferences`) + ).json()) as PreferencesBody; + + expect( + body.types.map(({ id, locked, mandatory }) => [id, locked, mandatory]), + ).toEqual([ + [COMMENT, false, false], + [LOCKED, true, false], + [SECURITY, true, true], + [NO_EMAIL, false, false], + ]); + }); + + it("answers 404 for an unknown user", async () => { + const h = await harness(); + + const response = await h.request("/999/notification-preferences"); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "User not found" }); + }); + + it("refuses staff without users:can_view", async () => { + const h = await harness({ editor: [] }); + + const response = await h.request(`/${TARGET_ID}/notification-preferences`); + + expect(response.status).toBe(403); + }); +}); + +describe("PUT /admin/users/{id}/notification-preferences", () => { + it("persists to the target user's state and answers with the new preferences", async () => { + const h = await harness({ + states: [ + { preferences: { [COMMENT]: { email: "none" } }, userId: EDITOR.id }, + { preferences: { [NO_EMAIL]: { inApp: false } }, userId: TARGET_ID }, + ], + }); + + const response = await h.put({ [COMMENT]: { email: "immediate" } }); + + expect(response.status).toBe(200); + const body = (await response.json()) as PreferencesBody; + expect(typeOf(body, COMMENT)?.value.email).toBe("immediate"); + expect(h.preferencesOf(TARGET_ID)).toEqual({ + [COMMENT]: { email: "immediate" }, + [NO_EMAIL]: { inApp: false }, + }); + expect(h.preferencesOf(EDITOR.id)).toEqual({ + [COMMENT]: { email: "none" }, + }); + expect(h.emit).toHaveBeenCalledWith("notifications.preferences_updated", { + typeIds: [COMMENT], + userId: TARGET_ID, + }); + }); + + it("creates the target user's state when they never saved preferences", async () => { + const h = await harness(); + + const response = await h.put({ [COMMENT]: { push: false } }); + + expect(response.status).toBe(200); + expect(h.preferencesOf(TARGET_ID)).toEqual({ [COMMENT]: { push: false } }); + }); + + it("refuses a type the installation locks for members", async () => { + const h = await harness(); + + const response = await h.put({ [LOCKED]: { email: "weekly" } }); + + expect(response.status).toBe(400); + expect(h.preferencesOf(TARGET_ID)).toBeUndefined(); + }); + + it.each([ + ["a mandatory type", { [SECURITY]: { email: "none" as const } }], + ["an unknown type", { "blog.unknown": { inApp: true } }], + [ + "an email channel the type lacks", + { [NO_EMAIL]: { email: "daily" as const } }, + ], + ])("rejects %s", async (_, types) => { + const h = await harness(); + + const response = await h.put(types); + + expect(response.status).toBe(400); + expect(h.preferencesOf(TARGET_ID)).toBeUndefined(); + }); + + it("rejects channels the installation switched off", async () => { + const h = await harness({ + policies: { [COMMENT]: { allowInApp: false, allowPush: false } }, + }); + + expect((await h.put({ [COMMENT]: { inApp: true } })).status).toBe(400); + expect((await h.put({ [COMMENT]: { push: true } })).status).toBe(400); + }); + + it("refuses staff without users:can_edit", async () => { + const h = await harness({ editor: [CAN_VIEW] }); + + const response = await h.put({ [COMMENT]: { email: "weekly" } }); + + expect(response.status).toBe(403); + expect(h.preferencesOf(TARGET_ID)).toBeUndefined(); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness({ targetIsStaff: true }); + + const response = await h.put({ [COMMENT]: { email: "weekly" } }); + + expect(response.status).toBe(403); + expect(h.preferencesOf(TARGET_ID)).toBeUndefined(); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.ts new file mode 100644 index 000000000..f76466e00 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.ts @@ -0,0 +1,50 @@ +import { buildRoute } from "@/api/lib/route"; +import { getNotificationPreferences } from "@/api/models/notifications/preferences"; +import { zodNotificationPreferences } from "@/api/modules/notifications/routes/preferences.route"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + findTargetUserId, + userNotFoundResponse, + zodTargetUserParams, +} from "../lib/target-user"; + +export const listUserNotificationPreferencesAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_view" }, + route: { + method: "get", + description: + "Every notification type the installation offers, with the channels it may use and what a user currently receives. Only mandatory types are locked for staff (Admin only)", + path: "/{id}/notification-preferences", + request: { + params: zodTargetUserParams, + }, + responses: { + 200: { + content: { + "application/json": { schema: zodNotificationPreferences }, + }, + description: "Notification preferences", + }, + 403: { + description: "Access Denied", + }, + 404: userNotFoundResponse, + }, + }, + handler: async c => { + const userId = await findTargetUserId(c, c.req.valid("param").id); + if (userId === null) { + return c.json({ error: "User not found" }, 404); + } + + return c.json( + await getNotificationPreferences(c, { + language: c.get("admin")?.user.language, + userId, + }), + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/passkey-delete.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/passkey-delete.route.ts new file mode 100644 index 000000000..d8e2327a0 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/passkey-delete.route.ts @@ -0,0 +1,68 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { passkeyFailure } from "@/api/modules/users/passkeys/failure"; +import { + PASSKEY_ERROR_RESPONSES, + zodPasskeyIdParam, +} from "@/api/modules/users/passkeys/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + USER_NOT_FOUND, + userNotFoundResponse, + zodTargetUserIdParam, +} from "../lib/target-user"; + +export const deleteUserPasskeyAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "delete", + description: + "Remove one of a user's passkeys. Refused when it is the account's last way to sign in (Admin only)", + path: "/{id}/passkeys/{passkeyId}", + request: { + params: z.object({ + id: zodTargetUserIdParam, + passkeyId: zodPasskeyIdParam, + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ ok: z.literal(true) }), + }, + }, + description: "Passkey removed", + }, + ...PASSKEY_ERROR_RESPONSES, + 404: { + ...userNotFoundResponse, + description: "Unknown user, unknown passkey, or passkeys are disabled", + }, + }, + }, + handler: async c => { + const { id, passkeyId } = c.req.valid("param"); + const userId = await findTargetUserId(c, id); + if (userId === null) return c.json(USER_NOT_FOUND, 404); + + await assertCanEditAdminTarget(c, userId); + + try { + await new PasskeyModel(c).deletePasskey({ + id: Number(passkeyId), + userId, + }); + + return c.json({ ok: true as const }, 200); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/passkey-rename.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/passkey-rename.route.ts new file mode 100644 index 000000000..6810fca74 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/passkey-rename.route.ts @@ -0,0 +1,79 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { passkeyFailure } from "@/api/modules/users/passkeys/failure"; +import { + PASSKEY_ERROR_RESPONSES, + zodPasskeyIdParam, + zodPasskeyNameSchema, + zodPasskeySchema, +} from "@/api/modules/users/passkeys/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + USER_NOT_FOUND, + userNotFoundResponse, + zodTargetUserIdParam, +} from "../lib/target-user"; + +export const renameUserPasskeyAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "patch", + description: "Rename one of a user's passkeys (Admin only)", + path: "/{id}/passkeys/{passkeyId}", + request: { + params: z.object({ + id: zodTargetUserIdParam, + passkeyId: zodPasskeyIdParam, + }), + body: { + required: true, + content: { + "application/json": { + schema: z.object({ name: zodPasskeyNameSchema }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: zodPasskeySchema, + }, + }, + description: "Passkey renamed", + }, + ...PASSKEY_ERROR_RESPONSES, + 404: { + ...userNotFoundResponse, + description: "Unknown user, unknown passkey, or passkeys are disabled", + }, + }, + }, + handler: async c => { + const { id, passkeyId } = c.req.valid("param"); + const { name } = c.req.valid("json"); + const userId = await findTargetUserId(c, id); + if (userId === null) return c.json(USER_NOT_FOUND, 404); + + await assertCanEditAdminTarget(c, userId); + + try { + const passkey = await new PasskeyModel(c).renamePasskey({ + id: Number(passkeyId), + name, + userId, + }); + + return c.json(passkey, 200); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.test.ts new file mode 100644 index 000000000..97c7ba0ca --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.test.ts @@ -0,0 +1,350 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; + +import { buildModule } from "@/api/lib/module"; +import { PasskeyModel } from "@/api/models/passkey"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_admin_permissions } from "@/database/admins"; +import { core_moderators_permissions } from "@/database/moderators"; +import { core_roles } from "@/database/roles"; +import { core_users, core_users_secondary_roles } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { + createMemoryPasskeyStore, + type MemoryPasskeyAccount, +} from "@/tests/passkey-store"; +import { SESSION_AUTHORIZATION } from "@/tests/sessions"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { deleteUserPasskeyAdminRoute } from "./passkey-delete.route"; +import { renameUserPasskeyAdminRoute } from "./passkey-rename.route"; +import { listUserPasskeysAdminRoute } from "./passkeys.route"; + +const MEMBER_ROLE = 3; +const EDITOR = { email: "editor@example.com", id: 1, roleId: MEMBER_ROLE }; +const TARGET_ID = 7; +const OTHER_ID = 8; +const MODERATOR_ID = 9; + +const permission = (name: string): PermissionsStaffArgs => ({ + module: "users", + permission: name, + plugin: "@vitnode/core", +}); + +const CAN_VIEW = permission("can_view"); +const CAN_EDIT = permission("can_edit"); +const CAN_EDIT_ADMIN = permission("can_edit_admin"); + +const userRow = (id: number) => ({ + email: `${id}@example.com`, + id, + name: `user-${id}`, + nameCode: `user-${id}`, + roleId: MEMBER_ROLE, +}); + +const newPasskey = (userId: number, name: string) => ({ + aaguid: null, + backedUp: true, + counter: 0, + credentialId: `credential-${userId}-${name}`, + deviceType: "multiDevice", + name, + publicKey: "public-key", + transports: ["internal"], + userId, + webauthnUserId: `webauthn-${userId}`, +}); + +const harness = async ({ + accounts = { + [MODERATOR_ID]: { hasPassword: true, ssoProviders: [] }, + [OTHER_ID]: { hasPassword: true, ssoProviders: [] }, + [TARGET_ID]: { hasPassword: true, ssoProviders: [] }, + }, + editor, +}: { + accounts?: Record; + editor: PermissionsStaffArgs[]; +}) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: editor, + userId: EDITOR.id, + }); + + const memory = createMemoryPasskeyStore(accounts); + vi.spyOn(PasskeyModel.prototype, "store", "get").mockReturnValue( + memory.store, + ); + const targetKey = await memory.store.createPasskey( + newPasskey(TARGET_ID, "Laptop"), + ); + const otherKey = await memory.store.createPasskey( + newPasskey(OTHER_ID, "Phone"), + ); + const moderatorKey = await memory.store.createPasskey( + newPasskey(MODERATOR_ID, "Key"), + ); + if (!(targetKey && otherKey && moderatorKey)) { + throw new Error("seed failed"); + } + + const memoryDb = createMemoryDb([ + [ + core_users, + [EDITOR, userRow(TARGET_ID), userRow(OTHER_ID), userRow(MODERATOR_ID)], + ], + [core_users_secondary_roles, []], + [core_roles, [{ guest: false, id: MEMBER_ROLE, root: false }]], + [core_admin_permissions, []], + [ + core_moderators_permissions, + [ + { + permissions: [], + roleId: null, + unrestricted: false, + userId: MODERATOR_ID, + }, + ], + ], + ]); + const emit = vi.fn(async () => Promise.resolve(undefined)); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("core", { + authorization: { + ...SESSION_AUTHORIZATION, + passkeys: { + enabled: true, + origins: ["https://example.com"], + rpId: "example.com", + rpName: "VitNode", + }, + }, + } as unknown as EnvVariablesVitNode["core"]); + c.set("admin", { user: EDITOR } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", memoryDb.db as unknown as Context["var"]["db"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + await next(); + }); + app.route( + "/", + buildModule({ + name: "users", + pluginId: CONFIG_PLUGIN.pluginId, + routes: [ + listUserPasskeysAdminRoute, + renameUserPasskeyAdminRoute, + deleteUserPasskeyAdminRoute, + ], + }).hono, + ); + + const list = async (userId: number) => + await app.request(`/${userId}/passkeys`); + + const rename = async (userId: number, passkeyId: number, name: string) => + await app.request(`/${userId}/passkeys/${passkeyId}`, { + body: JSON.stringify({ name }), + headers: { "content-type": "application/json" }, + method: "PATCH", + }); + + const remove = async (userId: number, passkeyId: number) => + await app.request(`/${userId}/passkeys/${passkeyId}`, { + method: "DELETE", + }); + + return { + emit, + keys: { moderator: moderatorKey, other: otherKey, target: targetKey }, + list, + passkeys: memory.passkeys, + remove, + rename, + }; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("GET /admin/users/{id}/passkeys", () => { + it("lists only that user's passkeys, without secrets", async () => { + const h = await harness({ editor: [CAN_VIEW] }); + + const response = await h.list(TARGET_ID); + + expect(response.status).toBe(200); + const { items } = (await response.json()) as { + items: Record[]; + }; + expect(items).toEqual([ + expect.objectContaining({ + backedUp: true, + deviceType: "multiDevice", + id: h.keys.target.id, + lastUsedAt: null, + name: "Laptop", + transports: ["internal"], + }), + ]); + expect(items[0]).not.toHaveProperty("publicKey"); + expect(items[0]).not.toHaveProperty("credentialId"); + }); + + it("returns 404 for an unknown user", async () => { + const h = await harness({ editor: [CAN_VIEW] }); + + const response = await h.list(404); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "User not found" }); + }); + + it("refuses a caller without users:can_view", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + expect((await h.list(TARGET_ID)).status).toBe(403); + }); +}); + +describe("PATCH /admin/users/{id}/passkeys/{passkeyId}", () => { + it("renames the user's passkey and emits the update", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.rename(TARGET_ID, h.keys.target.id, " Desk "); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + id: h.keys.target.id, + name: "Desk", + }); + expect(h.passkeys.get(h.keys.target.id)?.name).toBe("Desk"); + expect(h.emit).toHaveBeenCalledWith("user.passkey.updated", { + name: "Desk", + passkeyId: h.keys.target.id, + userId: TARGET_ID, + }); + }); + + it("returns not_found for another user's passkey", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.rename(TARGET_ID, h.keys.other.id, "Stolen"); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "not_found" }); + expect(h.passkeys.get(h.keys.other.id)?.name).toBe("Phone"); + }); + + it("rejects a blank name", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + expect((await h.rename(TARGET_ID, h.keys.target.id, " ")).status).toBe( + 400, + ); + }); + + it("refuses a caller without users:can_edit", async () => { + const h = await harness({ editor: [CAN_VIEW] }); + + expect((await h.rename(TARGET_ID, h.keys.target.id, "Desk")).status).toBe( + 403, + ); + expect(h.passkeys.get(h.keys.target.id)?.name).toBe("Laptop"); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.rename(MODERATOR_ID, h.keys.moderator.id, "Desk"); + + expect(response.status).toBe(403); + expect(h.passkeys.get(h.keys.moderator.id)?.name).toBe("Key"); + }); + + it("lets users:can_edit_admin rename a staff target's passkey", async () => { + const h = await harness({ editor: [CAN_EDIT, CAN_EDIT_ADMIN] }); + + const response = await h.rename(MODERATOR_ID, h.keys.moderator.id, "Desk"); + + expect(response.status).toBe(200); + }); +}); + +describe("DELETE /admin/users/{id}/passkeys/{passkeyId}", () => { + it("removes the user's passkey and emits the deletion", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.remove(TARGET_ID, h.keys.target.id); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(h.passkeys.has(h.keys.target.id)).toBe(false); + expect(h.emit).toHaveBeenCalledWith("user.passkey.deleted", { + passkeyId: h.keys.target.id, + userId: TARGET_ID, + }); + }); + + it("refuses removing the account's last way to sign in", async () => { + const h = await harness({ + accounts: { [TARGET_ID]: { hasPassword: false, ssoProviders: [] } }, + editor: [CAN_EDIT], + }); + + const response = await h.remove(TARGET_ID, h.keys.target.id); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: "last_recovery_method" }); + expect(h.passkeys.has(h.keys.target.id)).toBe(true); + }); + + it("returns not_found for another user's passkey", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.remove(TARGET_ID, h.keys.other.id); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "not_found" }); + expect(h.passkeys.has(h.keys.other.id)).toBe(true); + }); + + it("returns 404 for an unknown user", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.remove(404, h.keys.target.id); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "User not found" }); + }); + + it("refuses a caller without users:can_edit", async () => { + const h = await harness({ editor: [CAN_VIEW] }); + + expect((await h.remove(TARGET_ID, h.keys.target.id)).status).toBe(403); + expect(h.passkeys.has(h.keys.target.id)).toBe(true); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + expect((await h.remove(MODERATOR_ID, h.keys.moderator.id)).status).toBe( + 403, + ); + expect(h.passkeys.has(h.keys.moderator.id)).toBe(true); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.ts new file mode 100644 index 000000000..8feae4748 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.ts @@ -0,0 +1,58 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { passkeyFailure } from "@/api/modules/users/passkeys/failure"; +import { + PASSKEY_ERROR_RESPONSES, + zodPasskeySchema, +} from "@/api/modules/users/passkeys/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + findTargetUserId, + USER_NOT_FOUND, + userNotFoundResponse, + zodTargetUserIdParam, +} from "../lib/target-user"; + +export const listUserPasskeysAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_view" }, + route: { + method: "get", + description: "List a user's passkeys (Admin only)", + path: "/{id}/passkeys", + request: { + params: z.object({ id: zodTargetUserIdParam }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ items: z.array(zodPasskeySchema) }), + }, + }, + description: "The user's passkeys", + }, + ...PASSKEY_ERROR_RESPONSES, + 404: { + ...userNotFoundResponse, + description: "Unknown user, or passkeys are disabled", + }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const userId = await findTargetUserId(c, id); + if (userId === null) return c.json(USER_NOT_FOUND, 404); + + try { + const items = await new PasskeyModel(c).listPasskeys(userId); + + return c.json({ items }, 200); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/password.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/password.route.test.ts new file mode 100644 index 000000000..124211a1d --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/password.route.test.ts @@ -0,0 +1,239 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; + +import { PasswordModel } from "@/api/models/password"; +import { + adminSessionCacheKey, + sessionCacheKey, +} from "@/api/models/session-cache"; +import { core_admin_sessions } from "@/database/admins"; +import { core_moderators_permissions } from "@/database/moderators"; +import { core_sessions } from "@/database/sessions"; +import { core_users } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { setPasswordUserAdminRoute } from "./password.route"; + +const EDITOR = { email: "editor@example.com", id: 1, roleId: 2 }; +const TARGET_ID = 7; +const OTHER_ID = 8; +const OLD_HASH = "old-salt:old-key"; +const NEW_PASSWORD = "Sup3rSecret!"; + +const permission = (name: string): PermissionsStaffArgs => ({ + module: "users", + permission: name, + plugin: "@vitnode/core", +}); + +const CAN_EDIT = permission("can_edit"); + +const session = (id: number, userId: number, deviceId: number) => ({ + deviceId, + expiresAt: new Date(), + id, + token: `token-${id}`, + userId, +}); + +const harness = async ({ + editor, + passwordEnabled = true, + targetIsModerator = false, +}: { + editor: PermissionsStaffArgs[]; + passwordEnabled?: boolean; + targetIsModerator?: boolean; +}) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: editor, + userId: EDITOR.id, + }); + + const memory = createMemoryDb([ + [ + core_users, + [ + EDITOR, + { id: TARGET_ID, password: OLD_HASH, roleId: 3 }, + { id: OTHER_ID, password: OLD_HASH, roleId: 3 }, + ], + ], + [ + core_sessions, + [ + session(1, TARGET_ID, 10), + session(2, TARGET_ID, 11), + session(3, OTHER_ID, 12), + ], + ], + [core_admin_sessions, [session(4, TARGET_ID, 10)]], + [ + core_moderators_permissions, + targetIsModerator + ? [ + { + permissions: [], + roleId: null, + unrestricted: false, + userId: TARGET_ID, + }, + ] + : [], + ], + ]); + + await Promise.all([ + cache.setSystem(sessionCacheKey("token-1", 10), { id: TARGET_ID }), + cache.setSystem(adminSessionCacheKey("token-4", 10), { id: TARGET_ID }), + ]); + + const emit = vi.fn(async () => Promise.resolve(undefined)); + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: EDITOR } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("core", { + authorization: { password: { enabled: passwordEnabled } }, + } as unknown as EnvVariablesVitNode["core"]); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + await next(); + }); + app.openapi( + setPasswordUserAdminRoute.route, + setPasswordUserAdminRoute.handler, + ); + + const put = async (userId: number | string, password: string) => + await app.request(`/${userId}/password`, { + body: JSON.stringify({ password }), + headers: { "content-type": "application/json" }, + method: "PUT", + }); + + const storedHash = (userId: number) => + memory.rows(core_users).find(row => row.id === userId)?.password; + + const sessionUserIds = () => + memory.rows(core_sessions).map(row => row.userId); + + const adminSessionUserIds = () => + memory.rows(core_admin_sessions).map(row => row.userId); + + return { + adminSessionUserIds, + cache, + emit, + put, + sessionUserIds, + storedHash, + }; +}; + +describe("PUT /admin/users/{id}/password", () => { + it("stores a hash that verifies against the new password", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.put(TARGET_ID, NEW_PASSWORD); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + const stored = h.storedHash(TARGET_ID); + expect(typeof stored).toBe("string"); + expect(stored).not.toBe(NEW_PASSWORD); + expect( + await new PasswordModel().verifyPassword(NEW_PASSWORD, String(stored)), + ).toBe(true); + expect(h.storedHash(OTHER_ID)).toBe(OLD_HASH); + }); + + it("signs the member out of every device and drops their cached sessions", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + await h.put(TARGET_ID, NEW_PASSWORD); + + expect(h.sessionUserIds()).toEqual([OTHER_ID]); + expect(h.adminSessionUserIds()).toEqual([]); + expect(await h.cache.getSystem(sessionCacheKey("token-1", 10))).toBeNull(); + expect( + await h.cache.getSystem(adminSessionCacheKey("token-4", 10)), + ).toBeNull(); + }); + + it("emits the password change and the sign-out of every device", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + await h.put(TARGET_ID, NEW_PASSWORD); + + expect(h.emit).toHaveBeenCalledWith("user.password.updated", { + userId: TARGET_ID, + }); + expect(h.emit).toHaveBeenCalledWith("user.sessions.revoked", { + deviceId: null, + sessions: 3, + userId: TARGET_ID, + }); + }); + + it("refuses while password sign-in is disabled and changes nothing", async () => { + const h = await harness({ editor: [CAN_EDIT], passwordEnabled: false }); + + const response = await h.put(TARGET_ID, NEW_PASSWORD); + + expect(response.status).toBe(403); + expect(h.storedHash(TARGET_ID)).toBe(OLD_HASH); + expect(h.sessionUserIds()).toHaveLength(3); + expect(h.adminSessionUserIds()).toEqual([TARGET_ID]); + expect(h.emit).not.toHaveBeenCalled(); + }); + + it("refuses an editor without users:can_edit", async () => { + const h = await harness({ editor: [permission("can_view")] }); + + const response = await h.put(TARGET_ID, NEW_PASSWORD); + + expect(response.status).toBe(403); + expect(h.storedHash(TARGET_ID)).toBe(OLD_HASH); + expect(h.sessionUserIds()).toHaveLength(3); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness({ editor: [CAN_EDIT], targetIsModerator: true }); + + const response = await h.put(TARGET_ID, NEW_PASSWORD); + + expect(response.status).toBe(403); + expect(h.storedHash(TARGET_ID)).toBe(OLD_HASH); + }); + + it.each([404, "not-a-number"])( + "answers 404 for the unknown user %s", + async userId => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.put(userId, NEW_PASSWORD); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "User not found" }); + }, + ); + + it("refuses a password shorter than eight characters", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.put(TARGET_ID, "short"); + + expect(response.status).toBe(400); + expect(h.storedHash(TARGET_ID)).toBe(OLD_HASH); + }); +}); 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 new file mode 100644 index 000000000..54558d73b --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts @@ -0,0 +1,97 @@ +import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; + +import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in"; +import { buildRoute } from "@/api/lib/route"; +import { PasswordModel } from "@/api/models/password"; +import { revokeSessions } from "@/api/models/session-revoke"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_users } from "@/database/users"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; + +export const zodSetPasswordUserAdminSchema = z.object({ + password: z.string().min(8).openapi({ example: "Test123!" }), +}); + +export const setPasswordUserAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "put", + description: + "Set a user's password by id and sign them out of every device (Admin only)", + path: "/{id}/password", + request: { + params: z.object({ + id: z.string().openapi({ example: "1" }), + }), + body: { + required: true, + content: { + "application/json": { + schema: zodSetPasswordUserAdminSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ ok: z.literal(true) }), + }, + }, + description: "Password set and every session revoked", + }, + 400: { + description: "Invalid password", + }, + 403: { + description: "Access Denied, or password sign-in is disabled", + }, + 404: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "User not found", + }, + }, + }, + handler: async c => { + assertPasswordSignInEnabled(c); + const { id } = c.req.valid("param"); + const { password } = c.req.valid("json"); + const userId = Number(id); + if (!Number.isInteger(userId)) { + return c.json({ error: "User not found" }, 404); + } + + const db = c.get("db"); + + const [user] = await db + .select({ id: core_users.id }) + .from(core_users) + .where(eq(core_users.id, userId)) + .limit(1); + + if (!user) { + return c.json({ error: "User not found" }, 404); + } + + 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 c.get("events").emit("user.password.updated", { userId: user.id }); + + await revokeSessions(c, { userId: user.id }); + + return c.json({ ok: true as const }, 200); + }, +}); 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 new file mode 100644 index 000000000..e95bcb470 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/revoke-device.route.ts @@ -0,0 +1,70 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { revokeSessions } from "@/api/models/session-revoke"; +import { findUserDeviceId } from "@/api/models/user-devices"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + USER_NOT_FOUND, + zodTargetUserIdParam, +} from "../lib/target-user"; + +export const revokeUserDeviceAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "delete", + description: + "Sign a user out of one device, ending both its user and AdminCP sessions (Admin only)", + path: "/{id}/devices/{publicId}", + request: { + params: z.object({ + id: zodTargetUserIdParam, + publicId: z + .string() + .regex(/^[A-Za-z0-9_-]{1,128}$/) + .openapi({ example: "a1b2c3" }), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ ok: z.literal(true) }), + }, + }, + description: "Device signed out", + }, + 403: { + description: "Access Denied", + }, + 404: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "Unknown user, or the user has no session on that device", + }, + }, + }, + handler: async c => { + const { id, publicId } = c.req.valid("param"); + const userId = await findTargetUserId(c, id); + if (userId === null) return c.json(USER_NOT_FOUND, 404); + + await assertCanEditAdminTarget(c, userId); + + const deviceId = await findUserDeviceId(c, { publicId, userId }); + if (deviceId === null) { + return c.json({ error: "Device not found" }, 404); + } + + await revokeSessions(c, { deviceId, userId }); + + 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 new file mode 100644 index 000000000..c88e1e256 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/revoke-devices.route.ts @@ -0,0 +1,52 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { revokeSessions } from "@/api/models/session-revoke"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + USER_NOT_FOUND, + userNotFoundResponse, + zodTargetUserIdParam, +} from "../lib/target-user"; + +export const revokeUserDevicesAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "delete", + description: + "Sign a user out of every device, ending all user and AdminCP sessions (Admin only)", + path: "/{id}/devices", + request: { + params: z.object({ id: zodTargetUserIdParam }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ ok: z.literal(true) }), + }, + }, + description: "Every session of the user ended", + }, + 403: { + description: "Access Denied", + }, + 404: userNotFoundResponse, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const userId = await findTargetUserId(c, id); + if (userId === null) return c.json(USER_NOT_FOUND, 404); + + await assertCanEditAdminTarget(c, userId); + + await revokeSessions(c, { userId }); + + return c.json({ ok: true as const }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/show.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/show.route.ts index 2023c2965..fcb2d0601 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/show.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/show.route.ts @@ -31,6 +31,11 @@ export const showUserAdminRoute = buildRoute({ name: z.string(), email: z.string(), nameCode: z.string(), + firstName: z.string().nullable(), + lastName: z.string().nullable(), + phone: z.string().nullable(), + headline: z.string().nullable(), + showRealName: z.boolean(), createdAt: z.date(), newsletter: z.boolean(), avatarColor: z.string(), @@ -42,6 +47,7 @@ export const showUserAdminRoute = buildRoute({ secondaryRoles: z.array(userRoleSchema), birthday: z.date().nullable(), language: z.string(), + timeZone: z.string().nullable(), isStaff: z.boolean(), imagePolicy: zodUserImagePolicy, }), diff --git a/packages/vitnode/src/api/modules/admin/users/routes/sso-disconnect.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/sso-disconnect.route.ts new file mode 100644 index 000000000..d202886d3 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/sso-disconnect.route.ts @@ -0,0 +1,71 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { SsoConnectionModel } from "@/api/models/sso-connection"; +import { ssoConnectionFailure } from "@/api/modules/users/sso/connections/failure"; +import { + zodSsoConnectionErrorSchema, + zodSsoProviderIdParam, +} from "@/api/modules/users/sso/connections/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + userNotFoundResponse, + zodTargetUserParams, +} from "../lib/target-user"; + +export const disconnectUserSsoAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "delete", + description: + "Remove a user's connection to a provider and its profile sync settings. Refused when it is the account's last way to sign in. Does not revoke anything at the provider (Admin only)", + path: "/{id}/sso/{providerId}", + request: { + params: zodTargetUserParams.extend({ + providerId: zodSsoProviderIdParam, + }), + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Connection removed", + }, + 403: { + description: "Access Denied", + }, + 404: { + ...userNotFoundResponse, + description: "User not found, or not connected to the provider", + }, + 409: { + content: { + "application/json": { schema: zodSsoConnectionErrorSchema }, + }, + description: "The connection is the account's last way to sign in", + }, + }, + }, + handler: async c => { + const { id, providerId } = c.req.valid("param"); + const userId = await findTargetUserId(c, id); + if (userId === null) { + return c.json({ error: "User not found" }, 404); + } + + await assertCanEditAdminTarget(c, userId); + + try { + await new SsoConnectionModel(c).disconnect({ providerId, userId }); + + return c.json({ ok: true as const }, 200); + } catch (error) { + return ssoConnectionFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/sso-preferences.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/sso-preferences.route.ts new file mode 100644 index 000000000..802dddc78 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/sso-preferences.route.ts @@ -0,0 +1,78 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { SsoConnectionModel } from "@/api/models/sso-connection"; +import { ssoConnectionFailure } from "@/api/modules/users/sso/connections/failure"; +import { zodSsoPreferencesSchema } from "@/api/modules/users/sso/connections/routes/preferences.route"; +import { zodSsoConnectionErrorSchema } from "@/api/modules/users/sso/connections/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + userNotFoundResponse, + zodTargetUserParams, +} from "../lib/target-user"; + +const ssoErrorResponse = (description: string) => ({ + content: { + "application/json": { schema: zodSsoConnectionErrorSchema }, + }, + description, +}); + +export const updateUserSsoPreferencesAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "put", + description: + "Choose which of a user's connected providers each profile field comes from (null keeps it managed manually), and whether signing in through a provider updates those fields (Admin only)", + path: "/{id}/sso/preferences", + request: { + params: zodTargetUserParams, + body: { + required: true, + content: { "application/json": { schema: zodSsoPreferencesSchema } }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Preferences saved", + }, + 400: ssoErrorResponse( + "A source or sync provider the user is not connected to", + ), + 403: { + description: "Access Denied", + }, + 404: userNotFoundResponse, + 409: ssoErrorResponse("A connection was removed while saving"), + }, + }, + handler: async c => { + const userId = await findTargetUserId(c, c.req.valid("param").id); + if (userId === null) { + return c.json({ error: "User not found" }, 404); + } + + await assertCanEditAdminTarget(c, userId); + + const { sources, sync } = c.req.valid("json"); + + try { + await new SsoConnectionModel(c).savePreferences({ + sources, + sync, + userId, + }); + + return c.json({ ok: true as const }, 200); + } catch (error) { + return ssoConnectionFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/sso.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/sso.route.test.ts new file mode 100644 index 000000000..184163c77 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/sso.route.test.ts @@ -0,0 +1,365 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { SsoConnectionModel } from "@/api/models/sso-connection"; +import { core_admin_permissions } from "@/database/admins"; +import { core_moderators_permissions } from "@/database/moderators"; +import { core_roles } from "@/database/roles"; +import { core_users, core_users_secondary_roles } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { + createMemorySsoConnectionStore, + type MemorySsoAccount, +} from "@/tests/sso-connection-store"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { disconnectUserSsoAdminRoute } from "./sso-disconnect.route"; +import { updateUserSsoPreferencesAdminRoute } from "./sso-preferences.route"; +import { listUserSsoAdminRoute } from "./sso.route"; + +const STAFF_ROLE = 2; +const MEMBER_ROLE = 3; +const EDITOR = { email: "editor@example.com", id: 1, roleId: STAFF_ROLE }; +const TARGET_ID = 7; + +const permission = (name: string): PermissionsStaffArgs => ({ + module: "users", + permission: name, + plugin: "@vitnode/core", +}); + +const CAN_VIEW = permission("can_view"); +const CAN_EDIT = permission("can_edit"); +const CAN_EDIT_ADMIN = permission("can_edit_admin"); + +const account = ( + id: number, + overrides: Partial = {}, +): MemorySsoAccount => ({ + avatarId: null, + email: `user${id}@vitnode.test`, + firstName: null, + hasPassword: true, + id, + lastName: null, + name: `User${id}`, + passkeys: 0, + roleId: MEMBER_ROLE, + showRealName: false, + ...overrides, +}); + +const adapter = ( + id: string, + profileFields: SSOApiPlugin["profileFields"], +): SSOApiPlugin => ({ + fetchToken: async () => + await Promise.resolve({ access_token: "token", token_type: "Bearer" }), + fetchUser: async () => + await Promise.resolve({ email: "x@example.com", id: "x", username: "x" }), + getUrl: () => `https://${id}.example/oauth`, + id, + name: id, + profileFields, +}); + +const json = (body: unknown, method: string): RequestInit => ({ + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method, +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const harness = async ({ + editor = [CAN_VIEW, CAN_EDIT], + target = account(TARGET_ID), + targetIsStaff = false, +}: { + editor?: PermissionsStaffArgs[]; + target?: MemorySsoAccount; + targetIsStaff?: boolean; +} = {}) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: editor, + userId: EDITOR.id, + }); + if (targetIsStaff) { + await grantStaffPermissions(cache, { + permissions: [], + type: "moderator", + userId: TARGET_ID, + }); + } + + const memory = createMemoryDb([ + [ + core_users, + [ + EDITOR, + { + email: target.email, + id: TARGET_ID, + name: "Target", + nameCode: "target", + roleId: MEMBER_ROLE, + }, + ], + ], + [core_users_secondary_roles, []], + [ + core_roles, + [ + { guest: false, id: STAFF_ROLE, root: false }, + { guest: false, id: MEMBER_ROLE, root: false }, + ], + ], + [core_admin_permissions, []], + [core_moderators_permissions, []], + ]); + + const sso = createMemorySsoConnectionStore([account(EDITOR.id), target]); + vi.spyOn(SsoConnectionModel.prototype, "store", "get").mockReturnValue( + sso.store, + ); + const emit = vi.fn(async () => Promise.resolve(undefined)); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: EDITOR } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("core", { + authorization: { + passkeys: { enabled: false, problems: [] }, + password: { enabled: true }, + ssoAdapters: [ + adapter("google", ["avatar", "firstName", "lastName"]), + adapter("discord", ["avatar"]), + ], + }, + } as unknown as EnvVariablesVitNode["core"]); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + await next(); + }); + for (const { handler, route } of [ + listUserSsoAdminRoute, + disconnectUserSsoAdminRoute, + updateUserSsoPreferencesAdminRoute, + ]) { + app.openapi(route, handler); + } + + return { emit, request: app.request.bind(app), sso }; +}; + +describe("GET /admin/users/{id}/sso", () => { + it("returns the target user's connections, sign-in methods and sources", async () => { + const h = await harness(); + h.sso.connect(TARGET_ID, "google", "g-7", { + providerEmail: "target@gmail.com", + }); + h.sso.connect(EDITOR.id, "discord", "d-1"); + h.sso.sources.push({ + field: "firstName", + providerId: "google", + userId: TARGET_ID, + }); + + const response = await h.request(`/${TARGET_ID}/sso`); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + providers: { connection: null | { email: null | string }; id: string }[]; + signIn: { hasPassword: boolean }; + sources: Record; + }; + expect( + body.providers.map(one => [one.id, one.connection?.email ?? null]), + ).toEqual([ + ["google", "target@gmail.com"], + ["discord", null], + ]); + expect(body.providers[1]?.connection).toBeNull(); + expect(body.signIn.hasPassword).toBe(true); + expect(body.sources).toEqual({ + avatar: null, + firstName: "google", + lastName: null, + }); + }); + + it("answers 404 for an unknown or malformed user id", async () => { + const h = await harness(); + + for (const id of ["999", "abc", "1.5"]) { + const response = await h.request(`/${id}/sso`); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "User not found" }); + } + }); + + it("refuses staff without users:can_view", async () => { + const h = await harness({ editor: [] }); + + expect((await h.request(`/${TARGET_ID}/sso`)).status).toBe(403); + }); +}); + +describe("DELETE /admin/users/{id}/sso/{providerId}", () => { + it("removes the target user's connection and announces it", async () => { + const h = await harness(); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request(`/${TARGET_ID}/sso/google`, { + method: "DELETE", + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(h.sso.connections).toEqual([]); + expect(h.emit).toHaveBeenCalledWith("user.sso.unlinked", { + providerId: "google", + userId: TARGET_ID, + }); + }); + + it("refuses removing the account's last way to sign in", async () => { + const h = await harness({ + target: account(TARGET_ID, { hasPassword: false }), + }); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request(`/${TARGET_ID}/sso/google`, { + method: "DELETE", + }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: "last_sign_in_method" }); + expect(h.sso.connections).toHaveLength(1); + }); + + it("answers 404 when the user is not connected to the provider", async () => { + const h = await harness(); + + const response = await h.request(`/${TARGET_ID}/sso/google`, { + method: "DELETE", + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "not_connected" }); + }); + + it("refuses staff without users:can_edit", async () => { + const h = await harness({ editor: [CAN_VIEW] }); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request(`/${TARGET_ID}/sso/google`, { + method: "DELETE", + }); + + expect(response.status).toBe(403); + expect(h.sso.connections).toHaveLength(1); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness({ targetIsStaff: true }); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request(`/${TARGET_ID}/sso/google`, { + method: "DELETE", + }); + + expect(response.status).toBe(403); + expect(h.sso.connections).toHaveLength(1); + }); + + it("lets users:can_edit_admin disconnect a staff target", async () => { + const h = await harness({ + editor: [CAN_EDIT, CAN_EDIT_ADMIN], + targetIsStaff: true, + }); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request(`/${TARGET_ID}/sso/google`, { + method: "DELETE", + }); + + expect(response.status).toBe(200); + }); +}); + +describe("PUT /admin/users/{id}/sso/preferences", () => { + it("saves the target user's sources and sync settings", async () => { + const h = await harness(); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request( + `/${TARGET_ID}/sso/preferences`, + json({ sources: { firstName: "google" }, sync: { google: true } }, "PUT"), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(h.sso.sources).toEqual([ + { field: "firstName", providerId: "google", userId: TARGET_ID }, + ]); + expect(h.sso.connections[0]?.syncOnSignIn).toBe(true); + expect(h.emit).toHaveBeenCalledWith("user.sso.preferences_updated", { + sources: { firstName: "google" }, + sync: { google: true }, + userId: TARGET_ID, + }); + }); + + it("rejects a provider the target user is not connected to", async () => { + const h = await harness(); + h.sso.connect(EDITOR.id, "google", "g-1"); + + const response = await h.request( + `/${TARGET_ID}/sso/preferences`, + json({ sources: { firstName: "google" }, sync: {} }, "PUT"), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_source" }); + expect(h.emit).not.toHaveBeenCalled(); + }); + + it("rejects a field the provider does not supply", async () => { + const h = await harness(); + h.sso.connect(TARGET_ID, "discord", "d-7"); + + const response = await h.request( + `/${TARGET_ID}/sso/preferences`, + json({ sources: { lastName: "discord" }, sync: {} }, "PUT"), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_source" }); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness({ targetIsStaff: true }); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request( + `/${TARGET_ID}/sso/preferences`, + json({ sources: {}, sync: { google: true } }, "PUT"), + ); + + expect(response.status).toBe(403); + expect(h.sso.connections[0]?.syncOnSignIn).toBe(false); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/sso.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/sso.route.ts new file mode 100644 index 000000000..47b3f005a --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/sso.route.ts @@ -0,0 +1,44 @@ +import { buildRoute } from "@/api/lib/route"; +import { SsoConnectionModel } from "@/api/models/sso-connection"; +import { zodSsoConnectionsOverview } from "@/api/modules/users/sso/connections/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + findTargetUserId, + userNotFoundResponse, + zodTargetUserParams, +} from "../lib/target-user"; + +export const listUserSsoAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_view" }, + route: { + method: "get", + description: + "List the configured SSO providers with a user's connection to each, their sign-in methods and where their profile fields come from (Admin only)", + path: "/{id}/sso", + request: { + params: zodTargetUserParams, + }, + responses: { + 200: { + content: { + "application/json": { schema: zodSsoConnectionsOverview }, + }, + description: "Providers, connections and profile sources", + }, + 403: { + description: "Access Denied", + }, + 404: userNotFoundResponse, + }, + }, + handler: async c => { + const userId = await findTargetUserId(c, c.req.valid("param").id); + if (userId === null) { + return c.json({ error: "User not found" }, 404); + } + + return c.json(await new SsoConnectionModel(c).overview(userId), 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts index 26e4ca0b9..fc5cd7493 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts @@ -6,10 +6,17 @@ import { describe, expect, it, vi } from "vitest"; import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import { sessionCacheKey } from "@/api/models/session-cache"; import { core_admin_permissions } from "@/database/admins"; +import { core_languages } from "@/database/languages"; import { core_moderators_permissions } from "@/database/moderators"; import { core_roles } from "@/database/roles"; -import { core_users, core_users_secondary_roles } from "@/database/users"; +import { core_sessions } from "@/database/sessions"; +import { + core_users, + core_users_secondary_roles, + core_users_sso_profile_sources, +} from "@/database/users"; import { createTestCache } from "@/tests/cache"; import { createMemoryDb } from "@/tests/memory-db"; import { @@ -26,6 +33,7 @@ const PLUGINS_ROLE = 9; const EDITOR = { email: "editor@example.com", id: 1, roleId: EDITOR_ROLE }; const TARGET_ID = 7; +const TARGET_SESSION = { deviceId: 4, token: "target-session-token" }; const permission = (name: string, module = "users"): PermissionsStaffArgs => ({ module, @@ -105,16 +113,33 @@ const harness = async ({ core_moderators_permissions, target === "moderator" ? [entryRow({ userId: TARGET_ID })] : [], ], + [ + core_languages, + [ + { code: "en", id: 1, name: "English" }, + { code: "pl", id: 2, name: "Polski" }, + ], + ], + [ + core_sessions, + [{ ...TARGET_SESSION, expiresAt: new Date(), id: 1, userId: TARGET_ID }], + ], + [ + core_users_sso_profile_sources, + [ + { field: "firstName", providerId: "github", userId: TARGET_ID }, + { field: "lastName", providerId: "github", userId: TARGET_ID }, + ], + ], ]); + const emit = vi.fn(async () => Promise.resolve()); const app = new OpenAPIHono(); app.use("*", async (c, next) => { c.set("admin", { user: EDITOR } as unknown as Context["var"]["admin"]); c.set("cache", cache); c.set("db", memory.db as unknown as Context["var"]["db"]); - c.set("events", { - emit: vi.fn(async () => Promise.resolve()), - } as unknown as Context["var"]["events"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); await next(); }); app.openapi(updateUserAdminRoute.route, updateUserAdminRoute.handler); @@ -135,7 +160,13 @@ const harness = async ({ .filter(row => row.userId === userId) .map(row => row.roleId); - return { patch, secondaryRoleIdsOf, userRow }; + const ssoSourcesOf = (userId: number) => + memory + .rows(core_users_sso_profile_sources) + .filter(row => row.userId === userId) + .map(row => row.field); + + return { cache, emit, patch, secondaryRoleIdsOf, ssoSourcesOf, userRow }; }; const NEW_EMAIL = { email: "attacker@example.com" }; @@ -242,3 +273,142 @@ describe("PATCH /admin/users/{id} - privilege ceiling", () => { expect(h.userRow(TARGET_ID)?.roleId).toBe(EDITOR_ROLE); }); }); + +describe("PATCH /admin/users/{id} - personal information and preferences", () => { + it("saves personal fields trimmed and clears the SSO source of an edited name", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.patch(TARGET_ID, { + firstName: " Ada ", + headline: "Team Manager", + phone: "+48 600 700 800", + showRealName: true, + }); + + expect(response.status).toBe(200); + expect(h.userRow(TARGET_ID)).toMatchObject({ + firstName: "Ada", + headline: "Team Manager", + phone: "+48 600 700 800", + showRealName: true, + }); + expect(h.ssoSourcesOf(TARGET_ID)).toEqual(["lastName"]); + expect(h.emit).toHaveBeenCalledWith( + "user.updated", + expect.objectContaining({ userId: TARGET_ID }), + ); + }); + + it("clears personal fields with null or an empty string", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + await h.patch(TARGET_ID, { headline: "Lead", lastName: "Lovelace" }); + + const response = await h.patch(TARGET_ID, { headline: "", lastName: null }); + + expect(response.status).toBe(200); + expect(h.userRow(TARGET_ID)).toMatchObject({ + headline: null, + lastName: null, + }); + }); + + it("refuses a phone number with letters in it", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.patch(TARGET_ID, { phone: "call me maybe" }); + + expect(response.status).toBe(400); + expect(h.userRow(TARGET_ID)?.phone).toBeUndefined(); + }); + + it("stores a birthday at UTC midnight and clears it with null", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + expect((await h.patch(TARGET_ID, { birthday: "1990-04-21" })).status).toBe( + 200, + ); + expect(h.userRow(TARGET_ID)?.birthday).toEqual( + new Date("1990-04-21T00:00:00.000Z"), + ); + + expect((await h.patch(TARGET_ID, { birthday: null })).status).toBe(200); + expect(h.userRow(TARGET_ID)?.birthday).toBeNull(); + }); + + it("refuses a birthday that is not a real calendar date", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.patch(TARGET_ID, { birthday: "2023-02-30" }); + + expect(response.status).toBe(400); + }); + + it("switches to an installed language", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.patch(TARGET_ID, { language: "pl" }); + + expect(response.status).toBe(200); + expect(h.userRow(TARGET_ID)?.language).toBe("pl"); + }); + + it("refuses a language that is not installed", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.patch(TARGET_ID, { language: "xx" }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Invalid language" }); + expect(h.userRow(TARGET_ID)?.language).toBeUndefined(); + }); + + it("saves a time zone and resets it to automatic with null", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + await h.patch(TARGET_ID, { timeZone: "Europe/Warsaw" }); + expect(h.userRow(TARGET_ID)?.timeZone).toBe("Europe/Warsaw"); + + expect((await h.patch(TARGET_ID, { timeZone: null })).status).toBe(200); + expect(h.userRow(TARGET_ID)?.timeZone).toBeNull(); + }); + + it("refuses a time zone that does not exist", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.patch(TARGET_ID, { timeZone: "Mars/Olympus" }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Invalid time zone" }); + }); + + it("toggles the newsletter and drops the member's cached session", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + const key = sessionCacheKey(TARGET_SESSION.token, TARGET_SESSION.deviceId); + await h.cache.setSystem(key, { id: TARGET_ID }); + + const response = await h.patch(TARGET_ID, { newsletter: true }); + + expect(response.status).toBe(200); + expect(h.userRow(TARGET_ID)?.newsletter).toBe(true); + expect(await h.cache.getSystem(key)).toBeNull(); + }); + + it("refuses an editor without users:can_edit", async () => { + const h = await harness({ editor: [permission("can_view")] }); + + const response = await h.patch(TARGET_ID, { headline: "Hacked" }); + + expect(response.status).toBe(403); + expect(h.userRow(TARGET_ID)?.headline).toBeUndefined(); + }); + + it("refuses editing a staff target without users:can_edit_admin", async () => { + const h = await harness({ editor: [CAN_EDIT], target: "moderator" }); + + const response = await h.patch(TARGET_ID, { firstName: "Mallory" }); + + expect(response.status).toBe(403); + expect(h.userRow(TARGET_ID)?.firstName).toBeUndefined(); + expect(h.ssoSourcesOf(TARGET_ID)).toEqual(["firstName", "lastName"]); + }); +}); 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 87608c407..be20a741b 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 @@ -1,14 +1,25 @@ import { z } from "@hono/zod-openapi"; import { and, eq, inArray, ne } from "drizzle-orm"; +import { isValidTimeZone } from "@/api/lib/notifications/digest-period"; import { buildRoute } from "@/api/lib/route"; import { invalidateStaffPermissionsForUser } from "@/api/lib/staff-permission-cache"; import { matchesEmail } from "@/api/lib/user-email-lookup"; import { invalidateSessionCacheForUser } from "@/api/models/session-revoke"; +import { SsoConnectionModel } from "@/api/models/sso-connection"; import { CONFIG_PLUGIN } from "@/config"; +import { core_languages } from "@/database/languages"; import { core_roles } from "@/database/roles"; import { core_users, core_users_secondary_roles } from "@/database/users"; import { canonicalizeEmail } from "@/lib/email-canonical"; +import { + personalInformationChanges, + USER_FIRST_NAME_MAX_LENGTH, + USER_HEADLINE_MAX_LENGTH, + USER_LAST_NAME_MAX_LENGTH, + USER_PHONE_MAX_LENGTH, + USER_PHONE_PATTERN, +} from "@/lib/user-personal-information"; import { assertCanAssignRoles, @@ -16,6 +27,21 @@ import { } from "../lib/assert-edit-user-permission"; const nameRegex = /^(?!.* {2})[\p{L}\p{N}._@ -]*$/u; +const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +const nullableText = (max: number) => z.string().max(max).nullable(); + +const utcMidnightOf = (date: string): Date => new Date(`${date}T00:00:00.000Z`); + +const isCalendarDate = (value: string): boolean => { + if (!CALENDAR_DATE_PATTERN.test(value)) return false; + + const date = utcMidnightOf(value); + + return ( + !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value + ); +}; export const zodUpdateUserAdminSchema = z .object({ @@ -39,6 +65,34 @@ export const zodUpdateUserAdminSchema = z secondaryRoleIds: z .array(z.number().int().positive()) .openapi({ example: [2, 3] }), + firstName: nullableText(USER_FIRST_NAME_MAX_LENGTH).openapi({ + example: "Emirhan", + }), + lastName: nullableText(USER_LAST_NAME_MAX_LENGTH).openapi({ + example: "Boruch", + }), + phone: z + .string() + .max(USER_PHONE_MAX_LENGTH) + .refine(value => value === "" || USER_PHONE_PATTERN.test(value), { + message: "Invalid phone number", + }) + .nullable() + .openapi({ example: "+48 600 700 800" }), + headline: nullableText(USER_HEADLINE_MAX_LENGTH).openapi({ + example: "Team Manager", + }), + showRealName: z.boolean().openapi({ example: true }), + birthday: z + .string() + .refine(isCalendarDate, { message: "Invalid birthday" }) + .nullable() + .openapi({ example: "1990-04-21" }), + language: z.string().min(1).max(32).openapi({ example: "en" }), + timeZone: z.string().max(64).nullable().openapi({ + example: "Europe/Warsaw", + }), + newsletter: z.boolean().openapi({ example: false }), }) .partial() .refine(body => Object.values(body).some(value => value !== undefined), { @@ -50,7 +104,8 @@ export const updateUserAdminRoute = buildRoute({ adminStaffPermission: { module: "users", permission: "can_edit" }, route: { method: "patch", - description: "Update a user's name or email by id (Admin only)", + description: + "Update a user's account, roles, personal information and preferences by id (Admin only)", path: "/{id}", request: { params: z.object({ @@ -85,7 +140,7 @@ export const updateUserAdminRoute = buildRoute({ schema: z.object({ error: z.string() }), }, }, - description: "Invalid role", + description: "Invalid role, language or time zone", }, 403: { description: "Access Denied", @@ -181,6 +236,40 @@ export const updateUserAdminRoute = buildRoute({ values.nameCode = body.nameCode; } + if (body.language !== undefined) { + const [language] = await db + .select({ code: core_languages.code }) + .from(core_languages) + .where(eq(core_languages.code, body.language)) + .limit(1); + + if (!language) { + return c.json({ error: "Invalid language" }, 400); + } + + values.language = language.code; + } + + if (body.timeZone !== undefined) { + if (body.timeZone !== null && !isValidTimeZone(body.timeZone)) { + return c.json({ error: "Invalid time zone" }, 400); + } + + values.timeZone = body.timeZone; + } + + if (body.birthday !== undefined) { + values.birthday = + body.birthday === null ? null : utcMidnightOf(body.birthday); + } + + if (body.newsletter !== undefined) { + values.newsletter = body.newsletter; + } + + const personalValues = personalInformationChanges(body); + Object.assign(values, personalValues); + const effectivePrimaryId = body.roleId ?? user.roleId; const secondaryRoleIds = body.secondaryRoleIds !== undefined @@ -254,6 +343,13 @@ export const updateUserAdminRoute = buildRoute({ nameCode: core_users.nameCode, }); + await new SsoConnectionModel(c).clearSourcesAfterManualEdit({ + fields: (["firstName", "lastName"] as const).filter( + field => field in personalValues, + ), + userId: user.id, + }); + if (rolesChanged) { // Both caches, not just the permission one. `resolveStaffPermissions` // reads the primary role off the *cached user object*, so recomputing @@ -263,6 +359,8 @@ export const updateUserAdminRoute = buildRoute({ invalidateStaffPermissionsForUser(c, user.id), invalidateSessionCacheForUser(c, user.id), ]); + } else { + await invalidateSessionCacheForUser(c, user.id); } await c.get("events").emit("user.updated", { diff --git a/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts index 16b494656..abc6635fa 100644 --- a/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts @@ -2,10 +2,22 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; import { createUserAdminRoute } from "./routes/create.route"; +import { listUserDevicesAdminRoute } from "./routes/devices.route"; import { deleteUserImageAdminRoute } from "./routes/image-delete.route"; import { uploadUserImageAdminRoute } from "./routes/image-upload.route"; import { listUsersAdminRoute } from "./routes/list.route"; +import { updateUserNotificationPreferencesAdminRoute } from "./routes/notification-preferences-update.route"; +import { listUserNotificationPreferencesAdminRoute } from "./routes/notification-preferences.route"; +import { deleteUserPasskeyAdminRoute } from "./routes/passkey-delete.route"; +import { renameUserPasskeyAdminRoute } from "./routes/passkey-rename.route"; +import { listUserPasskeysAdminRoute } from "./routes/passkeys.route"; +import { setPasswordUserAdminRoute } from "./routes/password.route"; +import { revokeUserDeviceAdminRoute } from "./routes/revoke-device.route"; +import { revokeUserDevicesAdminRoute } from "./routes/revoke-devices.route"; import { showUserAdminRoute } from "./routes/show.route"; +import { disconnectUserSsoAdminRoute } from "./routes/sso-disconnect.route"; +import { updateUserSsoPreferencesAdminRoute } from "./routes/sso-preferences.route"; +import { listUserSsoAdminRoute } from "./routes/sso.route"; import { timelineUserAdminRoute } from "./routes/timeline.route"; import { updateUserAdminRoute } from "./routes/update.route"; import { verifyEmailUserAdminRoute } from "./routes/verify-email.route"; @@ -20,6 +32,18 @@ export const usersAdminModule = buildModule({ timelineUserAdminRoute, updateUserAdminRoute, verifyEmailUserAdminRoute, + setPasswordUserAdminRoute, + listUserDevicesAdminRoute, + revokeUserDeviceAdminRoute, + revokeUserDevicesAdminRoute, + listUserPasskeysAdminRoute, + renameUserPasskeyAdminRoute, + deleteUserPasskeyAdminRoute, + listUserSsoAdminRoute, + disconnectUserSsoAdminRoute, + updateUserSsoPreferencesAdminRoute, + listUserNotificationPreferencesAdminRoute, + updateUserNotificationPreferencesAdminRoute, uploadUserImageAdminRoute, deleteUserImageAdminRoute, ], diff --git a/packages/vitnode/src/api/modules/cron/helpers/load-cron-health.ts b/packages/vitnode/src/api/modules/cron/helpers/load-cron-health.ts new file mode 100644 index 000000000..5cc1b6ff7 --- /dev/null +++ b/packages/vitnode/src/api/modules/cron/helpers/load-cron-health.ts @@ -0,0 +1,17 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; + +import { core_cron } from "@/database/cron"; +import { type CronHealth, getCronHealth } from "@/lib/api/cron-health"; + +export const loadCronHealth = async ( + db: ReturnType, +): Promise => + getCronHealth( + await db + .select({ + createdAt: core_cron.createdAt, + lastRun: core_cron.lastRun, + schedule: core_cron.schedule, + }) + .from(core_cron), + ); diff --git a/packages/vitnode/src/api/modules/notifications/notifications.module.ts b/packages/vitnode/src/api/modules/notifications/notifications.module.ts new file mode 100644 index 000000000..ffbca9ec3 --- /dev/null +++ b/packages/vitnode/src/api/modules/notifications/notifications.module.ts @@ -0,0 +1,45 @@ +import { buildModule } from "@/api/lib/module"; +import { coreNotificationTypes } from "@/api/lib/notifications/core-types"; +import { CONFIG_PLUGIN } from "@/config"; + +import { listNotificationsRoute } from "./routes/list.route"; +import { + archiveNotificationRoute, + markAllNotificationsReadRoute, + markNotificationReadRoute, + markNotificationUnreadRoute, +} from "./routes/mark.route"; +import { + getNotificationPreferencesRoute, + updateNotificationPreferencesRoute, +} from "./routes/preferences.route"; +import { notificationStateRoute } from "./routes/state.route"; +import { + notificationsCleanupCron, + notificationsCleanupTask, + notificationsEmailTask, + notificationsFanoutTask, + notificationsScheduleCron, +} from "./tasks/notification-tasks"; + +export const notificationsModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "notifications", + routes: [ + listNotificationsRoute, + notificationStateRoute, + markAllNotificationsReadRoute, + markNotificationReadRoute, + markNotificationUnreadRoute, + archiveNotificationRoute, + getNotificationPreferencesRoute, + updateNotificationPreferencesRoute, + ], + cronJobs: [notificationsScheduleCron, notificationsCleanupCron], + queueTasks: [ + notificationsFanoutTask, + notificationsEmailTask, + notificationsCleanupTask, + ], + notificationTypes: coreNotificationTypes, +}); diff --git a/packages/vitnode/src/api/modules/notifications/routes/list.route.ts b/packages/vitnode/src/api/modules/notifications/routes/list.route.ts new file mode 100644 index 000000000..80d18d6ad --- /dev/null +++ b/packages/vitnode/src/api/modules/notifications/routes/list.route.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { listNotifications } from "@/api/models/notifications/inbox"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + requireNotificationUser, + unauthorizedResponse, + zodNotificationItem, +} from "../schema"; + +export const listNotificationsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: + "List the signed-in user's notifications, newest activity first. Listing never marks anything read.", + path: "/", + request: { + query: z.object({ + category: z.string().max(50).optional(), + cursor: z.string().max(200).optional(), + limit: z.coerce.number().int().min(1).max(50).default(20), + type: z.string().max(100).optional(), + unread: z.enum(["true", "false"]).optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + items: z.array(zodNotificationItem), + nextCursor: z.string().nullable(), + }), + }, + }, + description: "One page of notifications", + }, + ...unauthorizedResponse, + }, + }, + handler: async c => { + const user = requireNotificationUser(c); + const { category, cursor, limit, type, unread } = c.req.valid("query"); + + return c.json( + await listNotifications(c, { + category, + cursor, + language: user.language, + limit, + type, + unreadOnly: unread === "true", + userId: user.id, + }), + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/notifications/routes/mark.route.ts b/packages/vitnode/src/api/modules/notifications/routes/mark.route.ts new file mode 100644 index 000000000..c0bd5c0a0 --- /dev/null +++ b/packages/vitnode/src/api/modules/notifications/routes/mark.route.ts @@ -0,0 +1,148 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { + archiveNotification, + markAllNotificationsRead, + markNotificationRead, + markNotificationUnread, +} from "@/api/models/notifications/inbox"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + requireNotificationUser, + unauthorizedResponse, + zodNotificationIdParams, + zodNotificationState, +} from "../schema"; + +const stateResponse = { + 200: { + content: { "application/json": { schema: zodNotificationState } }, + description: "The unread state after the change", + }, + 404: { description: "No such notification for this user" }, + ...unauthorizedResponse, +} as const; + +export const markNotificationReadRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: + "Mark one notification read, up to the activity the user saw. Repeating it changes nothing.", + path: "/{id}/read", + request: { + params: zodNotificationIdParams, + body: { + required: false, + content: { + "application/json": { + schema: z.object({ + throughSeq: z.number().int().min(1).optional(), + }), + }, + }, + }, + }, + responses: stateResponse, + }, + handler: async c => { + const user = requireNotificationUser(c); + const { id } = c.req.valid("param"); + const body = c.req.header("content-type")?.includes("application/json") + ? c.req.valid("json") + : {}; + + return c.json( + await markNotificationRead(c, { + notificationId: id, + throughSeq: body.throughSeq, + userId: user.id, + }), + ); + }, +}); + +export const markNotificationUnreadRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Mark one notification unread again.", + path: "/{id}/unread", + request: { params: zodNotificationIdParams }, + responses: stateResponse, + }, + handler: async c => { + const user = requireNotificationUser(c); + const { id } = c.req.valid("param"); + + return c.json( + await markNotificationUnread(c, { notificationId: id, userId: user.id }), + ); + }, +}); + +export const archiveNotificationRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: + "Archive one notification. It leaves the inbox and the unread count; new activity brings it back.", + path: "/{id}/archive", + request: { params: zodNotificationIdParams }, + responses: stateResponse, + }, + handler: async c => { + const user = requireNotificationUser(c); + const { id } = c.req.valid("param"); + + return c.json( + await archiveNotification(c, { notificationId: id, userId: user.id }), + ); + }, +}); + +export const markAllNotificationsReadRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: + "Mark every notification read that exists when the request takes the user's lock. Anything delivered after stays unread.", + path: "/read-all", + request: { + body: { + required: false, + content: { + "application/json": { + schema: z.object({ category: z.string().max(50).optional() }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: zodNotificationState.extend({ marked: z.number() }), + }, + }, + description: "The unread state after the change", + }, + ...unauthorizedResponse, + }, + }, + handler: async c => { + const user = requireNotificationUser(c); + const body = c.req.header("content-type")?.includes("application/json") + ? c.req.valid("json") + : {}; + + return c.json( + await markAllNotificationsRead(c, { + category: body.category, + userId: user.id, + }), + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts b/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts new file mode 100644 index 000000000..1370a4f3a --- /dev/null +++ b/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { + getNotificationPreferences, + updateNotificationPreferences, +} from "@/api/models/notifications/preferences"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + requireNotificationUser, + unauthorizedResponse, + zodNotificationEmailMode, +} from "../schema"; + +export const zodNotificationPreferences = z.object({ + types: z.array( + z.object({ + category: z.string(), + categoryLabel: z.string(), + defaultEmail: zodNotificationEmailMode, + description: z.string().nullable(), + emailModes: z.array(zodNotificationEmailMode), + id: z.string(), + inAppAvailable: z.boolean(), + label: z.string(), + locked: z.boolean(), + mandatory: z.boolean(), + pluginId: z.string(), + pushAvailable: z.boolean(), + value: z.object({ + email: zodNotificationEmailMode, + inApp: z.boolean(), + push: z.boolean(), + }), + }), + ), +}); + +export const getNotificationPreferencesRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: + "Every notification type the installation offers, with the channels it may use and what the user currently receives.", + path: "/preferences", + responses: { + 200: { + content: { "application/json": { schema: zodNotificationPreferences } }, + description: "Notification preferences", + }, + ...unauthorizedResponse, + }, + }, + handler: async c => { + const user = requireNotificationUser(c); + + return c.json( + await getNotificationPreferences(c, { + language: user.language, + userId: user.id, + }), + ); + }, +}); + +export const zodUpdateNotificationPreferences = z.object({ + types: z + .record( + z.string().max(100), + z.object({ + email: zodNotificationEmailMode.optional(), + inApp: z.boolean().optional(), + push: z.boolean().optional(), + }), + ) + .optional(), +}); + +export const updateNotificationPreferencesRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "put", + description: + "Save notification preferences, digest schedule and time zone.", + path: "/preferences", + request: { + body: { + required: true, + content: { + "application/json": { schema: zodUpdateNotificationPreferences }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ success: z.boolean() }) }, + }, + description: "Saved", + }, + 400: { + description: "A type, mode or time zone this site does not offer", + }, + ...unauthorizedResponse, + }, + }, + handler: async c => { + const user = requireNotificationUser(c); + await updateNotificationPreferences(c, user.id, c.req.valid("json")); + + return c.json({ success: true }); + }, +}); diff --git a/packages/vitnode/src/api/modules/notifications/routes/state.route.ts b/packages/vitnode/src/api/modules/notifications/routes/state.route.ts new file mode 100644 index 000000000..07bb08077 --- /dev/null +++ b/packages/vitnode/src/api/modules/notifications/routes/state.route.ts @@ -0,0 +1,31 @@ +import { buildRoute } from "@/api/lib/route"; +import { getNotificationState } from "@/api/models/notifications/inbox"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + requireNotificationUser, + unauthorizedResponse, + zodNotificationState, +} from "../schema"; + +export const notificationStateRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: + "The signed-in user's unread count and its revision - one primary-key read, never a COUNT(*).", + path: "/state", + responses: { + 200: { + content: { "application/json": { schema: zodNotificationState } }, + description: "Unread state", + }, + ...unauthorizedResponse, + }, + }, + handler: async c => { + const user = requireNotificationUser(c); + + return c.json(await getNotificationState(c.get("db"), user.id)); + }, +}); diff --git a/packages/vitnode/src/api/modules/notifications/schema.ts b/packages/vitnode/src/api/modules/notifications/schema.ts new file mode 100644 index 000000000..538196838 --- /dev/null +++ b/packages/vitnode/src/api/modules/notifications/schema.ts @@ -0,0 +1,63 @@ +import type { Context } from "hono"; + +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import type { EnvVitNode } from "@/api/middlewares/global.middleware"; + +import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types"; + +export const zodNotificationState = z.object({ + revision: z.number(), + unread: z.number(), +}); + +const zodNotificationSubject = z.object({ + id: z.string(), + type: z.string(), +}); + +const zodNotificationActor = z.object({ + avatarColor: z.string(), + avatarUrl: z.string().nullable(), + id: z.number(), + name: z.string(), + nameCode: z.string(), +}); + +export const zodNotificationItem = z.object({ + activitySeq: z.number(), + actorCount: z.number(), + actors: z.array(zodNotificationActor), + available: z.boolean(), + body: z.string().nullable(), + category: z.string(), + createdAt: z.date(), + eventCount: z.number(), + id: z.number(), + lastActivityAt: z.date(), + pluginId: z.string(), + readAt: z.date().nullable(), + subject: zodNotificationSubject.nullable(), + target: z.string().nullable(), + title: z.string(), + type: z.string(), + unread: z.boolean(), +}); + +export const zodNotificationEmailMode = z.enum(NOTIFICATION_EMAIL_MODES); + +export const zodNotificationIdParams = z.object({ + id: z.coerce.number().int().positive(), +}); + +export const unauthorizedResponse = { + 401: { description: "Not signed in" }, +} as const; + +export const requireNotificationUser = (c: Context) => { + const user = c.get("user"); + if (!user) throw new HTTPException(401, { message: "Unauthorized" }); + + return user; +}; diff --git a/packages/vitnode/src/api/modules/notifications/tasks/notification-tasks.ts b/packages/vitnode/src/api/modules/notifications/tasks/notification-tasks.ts new file mode 100644 index 000000000..8a066bb00 --- /dev/null +++ b/packages/vitnode/src/api/modules/notifications/tasks/notification-tasks.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; + +import { buildCron } from "@/api/lib/cron"; +import { buildQueueTask } from "@/api/lib/queue"; +import { drainNotificationEmails } from "@/api/models/notifications/email"; +import { processNotificationEvent } from "@/api/models/notifications/fanout"; +import { + runNotificationCleanup, + runNotificationSchedule, +} from "@/api/models/notifications/maintenance"; +import { + NOTIFICATIONS_PLUGIN_ID, + QUEUE_NOTIFICATIONS_CLEANUP, + QUEUE_NOTIFICATIONS_EMAIL, + QUEUE_NOTIFICATIONS_FANOUT, +} from "@/api/models/notifications/shared"; + +const fanoutPayloadSchema = z.object({ eventId: z.number().int().positive() }); + +export const notificationsFanoutTask = buildQueueTask({ + name: QUEUE_NOTIFICATIONS_FANOUT, + description: + "Deliver a notification event to its recipients in bounded batches.", + maxAttempts: 10, + handler: async (c, payload) => { + const { eventId } = fanoutPayloadSchema.parse(payload); + const result = await processNotificationEvent(c, eventId); + + if (!result.done) { + await c.get("queue").dispatch({ + name: QUEUE_NOTIFICATIONS_FANOUT, + payload: { eventId }, + pluginId: NOTIFICATIONS_PLUGIN_ID, + priority: 10, + }); + } + }, +}); + +export const notificationsEmailTask = buildQueueTask({ + name: QUEUE_NOTIFICATIONS_EMAIL, + description: "Send due notification emails and digests.", + maxAttempts: 5, + handler: async c => { + await drainNotificationEmails(c); + }, +}); + +export const notificationsCleanupTask = buildQueueTask({ + name: QUEUE_NOTIFICATIONS_CLEANUP, + description: "Remove notifications older than the retention period.", + maxAttempts: 3, + handler: async c => { + await runNotificationCleanup(c); + }, +}); + +export const notificationsScheduleCron = buildCron({ + name: "notifications-schedule", + description: + "Plan notification digests, recover stalled deliveries and drain email.", + schedule: "*/5 * * * *", + handler: async c => { + await runNotificationSchedule(c); + }, +}); + +export const notificationsCleanupCron = buildCron({ + name: "notifications-cleanup", + description: "Queue the daily notification retention cleanup.", + schedule: "0 2 * * *", + handler: async c => { + await c.get("queue").dispatch({ + name: QUEUE_NOTIFICATIONS_CLEANUP, + pluginId: NOTIFICATIONS_PLUGIN_ID, + }); + }, +}); 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 85c920a2d..b70319b81 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 @@ -1,3 +1,4 @@ +import type { SQL } from "drizzle-orm"; import type { Context } from "hono"; import { and, asc, desc, eq, inArray, lt, lte, sql } from "drizzle-orm"; @@ -11,6 +12,146 @@ const QUEUE_BATCH_SIZE = 25; const QUEUE_LOCK_KEY = "queue:process"; const QUEUE_LOCK_TTL_SECONDS = 55; const QUEUE_RETENTION_DAYS = 7; +const QUEUE_STALE_PROCESSING_MINUTES = 15; + +type QueueRow = typeof core_queue.$inferSelect; + +const claimQueueTasks = async ( + c: Context, + where: SQL | undefined, + now: Date, +): Promise => + await c.get("db").transaction(async tx => { + const rows = await tx + .select({ id: core_queue.id }) + .from(core_queue) + .where( + and( + eq(core_queue.status, "pending"), + lte(core_queue.availableAt, now), + where, + ), + ) + .orderBy(desc(core_queue.priority), asc(core_queue.id)) + .limit(QUEUE_BATCH_SIZE) + .for("update", { skipLocked: true }); + + if (rows.length === 0) return []; + + return await tx + .update(core_queue) + .set({ + status: "processing", + reservedAt: now, + attempts: sql`${core_queue.attempts} + 1`, + }) + .where( + inArray( + core_queue.id, + rows.map(row => row.id), + ), + ) + .returning(); + }); + +const runClaimedQueueTasks = async ( + c: Context, + claimed: QueueRow[], +): Promise => { + if (claimed.length === 0) return; + + const handlerMap = new Map( + c.get("core").queue.map(task => [`${task.pluginId}:${task.name}`, task]), + ); + + for (const task of claimed) { + const key = `${task.pluginId}:${task.name}`; + const registered = handlerMap.get(key); + let error: null | string = null; + + if (!registered) { + error = `No handler registered for queue task "${key}"`; + await c.get("log").warn(error); + } else { + try { + await registered.handler(c, task.payload); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + await c.get("log").error(`Queue task "${key}" failed: ${error}`); + } + } + + const outcome = resolveQueueTaskOutcome({ + attempts: task.attempts, + maxAttempts: task.maxAttempts, + error, + }); + + await c + .get("db") + .update(core_queue) + .set({ + status: outcome.status, + lastError: outcome.lastError, + availableAt: outcome.availableAt ?? task.availableAt, + completedAt: outcome.completedAt ?? null, + reservedAt: null, + }) + .where(eq(core_queue.id, task.id)); + } +}; + +const reclaimStaleQueueTasks = async ( + c: Context, + now: Date, +): Promise => { + const stale = lt( + core_queue.reservedAt, + new Date(now.getTime() - QUEUE_STALE_PROCESSING_MINUTES * 60_000), + ); + const staleTaskError = "The worker stopped before the task finished."; + const db = c.get("db"); + + await db + .update(core_queue) + .set({ + completedAt: now, + lastError: staleTaskError, + reservedAt: null, + status: "failed", + }) + .where( + and( + eq(core_queue.status, "processing"), + stale, + sql`${core_queue.attempts} >= ${core_queue.maxAttempts}`, + ), + ); + + await db + .update(core_queue) + .set({ + availableAt: now, + lastError: staleTaskError, + reservedAt: null, + status: "pending", + }) + .where(and(eq(core_queue.status, "processing"), stale)); +}; + +export const processQueueTasksByIds = async ( + c: Context, + ids: number[], +): Promise => { + if (ids.length === 0) return; + + const claimed = await claimQueueTasks( + c, + inArray(core_queue.id, ids), + new Date(), + ); + await runClaimedQueueTasks(c, claimed); +}; export const processQueueTasks = async ( c: Context, @@ -24,80 +165,8 @@ export const processQueueTasks = async ( const db = c.get("db"); const now = new Date(); - const claimed = await db.transaction(async tx => { - const rows = await tx - .select({ id: core_queue.id }) - .from(core_queue) - .where( - and( - eq(core_queue.status, "pending"), - lte(core_queue.availableAt, now), - ), - ) - .orderBy(desc(core_queue.priority), asc(core_queue.id)) - .limit(QUEUE_BATCH_SIZE) - .for("update", { skipLocked: true }); - - if (rows.length === 0) return []; - - return tx - .update(core_queue) - .set({ - status: "processing", - reservedAt: now, - attempts: sql`${core_queue.attempts} + 1`, - }) - .where( - inArray( - core_queue.id, - rows.map(row => row.id), - ), - ) - .returning(); - }); - - if (claimed.length > 0) { - const handlerMap = new Map( - c - .get("core") - .queue.map(task => [`${task.pluginId}:${task.name}`, task]), - ); - - for (const task of claimed) { - const key = `${task.pluginId}:${task.name}`; - const registered = handlerMap.get(key); - let error: null | string = null; - - if (!registered) { - error = `No handler registered for queue task "${key}"`; - await c.get("log").warn(error); - } else { - try { - await registered.handler(c, task.payload); - } catch (err) { - error = err instanceof Error ? err.message : String(err); - await c.get("log").error(`Queue task "${key}" failed: ${error}`); - } - } - - const outcome = resolveQueueTaskOutcome({ - attempts: task.attempts, - maxAttempts: task.maxAttempts, - error, - }); - - await db - .update(core_queue) - .set({ - status: outcome.status, - lastError: outcome.lastError, - availableAt: outcome.availableAt ?? task.availableAt, - completedAt: outcome.completedAt ?? null, - reservedAt: null, - }) - .where(eq(core_queue.id, task.id)); - } - } + await reclaimStaleQueueTasks(c, now); + await runClaimedQueueTasks(c, await claimQueueTasks(c, undefined, now)); const cutoff = new Date( now.getTime() - QUEUE_RETENTION_DAYS * 24 * 60 * 60 * 1000, diff --git a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts index 539d0963c..86e50d623 100644 --- a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts @@ -86,6 +86,7 @@ export const changePasswordRoute = buildRoute({ .delete(core_users_forgot_password) .where(eq(core_users_forgot_password.id, user.id)), ]); + await c.get("events").emit("user.password.updated", { userId }); // After the new password is in place, so a failure above cannot sign // somebody out without having changed anything. Whoever reset this password diff --git a/packages/vitnode/src/api/modules/users/routes/devices.route.ts b/packages/vitnode/src/api/modules/users/routes/devices.route.ts index 10d2c7c60..d5aab7f27 100644 --- a/packages/vitnode/src/api/modules/users/routes/devices.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/devices.route.ts @@ -1,86 +1,13 @@ -import type { Context } from "hono"; - -import { and, eq, gt, inArray } from "drizzle-orm"; import { getCookie } from "hono/cookie"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; import { buildRoute } from "@/api/lib/route"; -import { CONFIG_PLUGIN } from "@/config"; -import { core_admin_sessions } from "@/database/admins"; import { - core_sessions, - core_sessions_known_devices, -} from "@/database/sessions"; -import { parseUserAgent } from "@/lib/api/parse-user-agent"; - -const SESSION_KINDS = ["user", "admin"] as const; -type SessionKind = (typeof SESSION_KINDS)[number]; - -interface ActiveSession { - deviceId: number; - expiresAt: Date; - kind: SessionKind; -} - -interface DeviceSessions { - expiresAt: Date; - kinds: Set; -} - -const activeSessionsOf = async ( - c: Context, - userId: number, -): Promise => { - const db = c.get("db"); - const now = new Date(); - - const [userSessions, adminSessions] = await Promise.all([ - db - .select({ - deviceId: core_sessions.deviceId, - expiresAt: core_sessions.expiresAt, - }) - .from(core_sessions) - .where( - and(eq(core_sessions.userId, userId), gt(core_sessions.expiresAt, now)), - ), - db - .select({ - deviceId: core_admin_sessions.deviceId, - expiresAt: core_admin_sessions.expiresAt, - }) - .from(core_admin_sessions) - .where( - and( - eq(core_admin_sessions.userId, userId), - gt(core_admin_sessions.expiresAt, now), - ), - ), - ]); - - return [ - ...userSessions.map(session => ({ ...session, kind: "user" as const })), - ...adminSessions.map(session => ({ ...session, kind: "admin" as const })), - ]; -}; - -const groupByDevice = ( - sessions: ActiveSession[], -): Map => { - const byDevice = new Map(); - for (const { deviceId, expiresAt, kind } of sessions) { - const existing = byDevice.get(deviceId); - if (!existing) { - byDevice.set(deviceId, { expiresAt, kinds: new Set([kind]) }); - continue; - } - existing.kinds.add(kind); - if (expiresAt > existing.expiresAt) existing.expiresAt = expiresAt; - } - - return byDevice; -}; + listUserDevices, + zodUserDeviceSchema, +} from "@/api/models/user-devices"; +import { CONFIG_PLUGIN } from "@/config"; export const listDevicesRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, @@ -95,17 +22,7 @@ export const listDevicesRoute = buildRoute({ "application/json": { schema: z.object({ devices: z.array( - z.object({ - publicId: z.string(), - ipAddress: z.string(), - os: z.string(), - browser: z.string(), - deviceType: z.enum(["desktop", "tablet", "mobile"]), - lastSeen: z.date(), - expiresAt: z.date(), - isCurrent: z.boolean(), - sessionKinds: z.array(z.enum(SESSION_KINDS)), - }), + zodUserDeviceSchema.extend({ isCurrent: z.boolean() }), ), }), }, @@ -128,45 +45,13 @@ export const listDevicesRoute = buildRoute({ c.get("core").authorization.deviceCookieName, ); - const sessionsByDevice = groupByDevice(await activeSessionsOf(c, user.id)); - if (sessionsByDevice.size === 0) { - return c.json({ devices: [] }); - } - - const rows = await c - .get("db") - .select({ - id: core_sessions_known_devices.id, - publicId: core_sessions_known_devices.publicId, - ipAddress: core_sessions_known_devices.ipAddress, - userAgent: core_sessions_known_devices.userAgent, - lastSeen: core_sessions_known_devices.lastSeen, - }) - .from(core_sessions_known_devices) - .where( - inArray(core_sessions_known_devices.id, [...sessionsByDevice.keys()]), - ); - - const devices = rows - .flatMap(({ id, userAgent, publicId, ...device }) => { - const sessions = sessionsByDevice.get(id); - if (!sessions) return []; - - return [ - { - ...device, - publicId, - ...parseUserAgent(userAgent), - expiresAt: sessions.expiresAt, - isCurrent: publicId === currentPublicId, - sessionKinds: SESSION_KINDS.filter(kind => - sessions.kinds.has(kind), - ), - }, - ]; - }) - .sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime()); + const devices = await listUserDevices(c, user.id); - return c.json({ devices }); + return c.json({ + devices: devices.map(device => ({ + ...device, + isCurrent: device.publicId === currentPublicId, + })), + }); }, }); diff --git a/packages/vitnode/src/api/modules/users/routes/revoke-device.route.ts b/packages/vitnode/src/api/modules/users/routes/revoke-device.route.ts index f8512bcca..9ae8f0ac1 100644 --- a/packages/vitnode/src/api/modules/users/routes/revoke-device.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/revoke-device.route.ts @@ -1,50 +1,11 @@ -import type { Context } from "hono"; - import { z } from "@hono/zod-openapi"; -import { and, eq } from "drizzle-orm"; import { getCookie } from "hono/cookie"; import { HTTPException } from "hono/http-exception"; import { buildRoute } from "@/api/lib/route"; import { revokeSessions } from "@/api/models/session-revoke"; +import { findUserDeviceId } from "@/api/models/user-devices"; import { CONFIG_PLUGIN } from "@/config"; -import { core_admin_sessions } from "@/database/admins"; -import { - core_sessions, - core_sessions_known_devices, -} from "@/database/sessions"; - -const hasSessionOn = async ( - c: Context, - { deviceId, userId }: { deviceId: number; userId: number }, -): Promise => { - const db = c.get("db"); - - const [userSessions, adminSessions] = await Promise.all([ - db - .select({ deviceId: core_sessions.deviceId }) - .from(core_sessions) - .where( - and( - eq(core_sessions.userId, userId), - eq(core_sessions.deviceId, deviceId), - ), - ) - .limit(1), - db - .select({ deviceId: core_admin_sessions.deviceId }) - .from(core_admin_sessions) - .where( - and( - eq(core_admin_sessions.userId, userId), - eq(core_admin_sessions.deviceId, deviceId), - ), - ) - .limit(1), - ]); - - return userSessions.length > 0 || adminSessions.length > 0; -}; export const revokeDeviceRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, @@ -97,20 +58,12 @@ export const revokeDeviceRoute = buildRoute({ return c.json({ error: "Cannot revoke the current device" }, 400); } - const db = c.get("db"); - const [device] = await db - .select({ id: core_sessions_known_devices.id }) - .from(core_sessions_known_devices) - .where(eq(core_sessions_known_devices.publicId, publicId)); - - if ( - !device || - !(await hasSessionOn(c, { deviceId: device.id, userId: user.id })) - ) { + const deviceId = await findUserDeviceId(c, { publicId, userId: user.id }); + if (deviceId === null) { return c.json({ error: "Device not found" }, 404); } - await revokeSessions(c, { deviceId: device.id, userId: user.id }); + await revokeSessions(c, { deviceId, userId: user.id }); return c.body(null, 200); }, diff --git a/packages/vitnode/src/api/modules/users/routes/session.route.ts b/packages/vitnode/src/api/modules/users/routes/session.route.ts index b5a11a775..89985c229 100644 --- a/packages/vitnode/src/api/modules/users/routes/session.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/session.route.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { getStaffFlags } from "@/api/lib/check-staff-permission"; import { buildRoute } from "@/api/lib/route"; +import { getNotificationState } from "@/api/models/notifications/inbox"; +import { zodNotificationState } from "@/api/modules/notifications/schema"; import { CONFIG_PLUGIN } from "@/config"; export const sessionRoute = buildRoute({ @@ -26,6 +28,7 @@ export const sessionRoute = buildRoute({ phone: z.string().nullable(), headline: z.string().nullable(), showRealName: z.boolean(), + timeZone: z.string().nullable(), createdAt: z.date(), newsletter: z.boolean(), avatarColor: z.string(), @@ -36,6 +39,7 @@ export const sessionRoute = buildRoute({ birthday: z.date().nullable(), isAdmin: z.boolean(), isModerator: z.boolean(), + notifications: zodNotificationState, }) .nullable(), }), @@ -49,8 +53,11 @@ export const sessionRoute = buildRoute({ const user = c.get("user"); if (!user) return c.json({ user: null }); - const { isAdmin, isModerator } = await getStaffFlags(c, user); + const [{ isAdmin, isModerator }, notifications] = await Promise.all([ + getStaffFlags(c, user), + getNotificationState(c.get("db"), user.id), + ]); - return c.json({ user: { ...user, isAdmin, isModerator } }); + return c.json({ user: { ...user, isAdmin, isModerator, notifications } }); }, }); diff --git a/packages/vitnode/src/api/modules/users/routes/time-zone.route.test.ts b/packages/vitnode/src/api/modules/users/routes/time-zone.route.test.ts new file mode 100644 index 000000000..a3277ad53 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/routes/time-zone.route.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { describe, expect, it, vi } from "vitest"; + +import { core_users } from "@/database/users"; +import { + createSessionWorld, + fakeSession, + sessionCookies, +} from "@/tests/sessions"; + +import { updateMyTimeZoneRoute } from "./time-zone.route"; + +const LAPTOP = { id: 3, publicId: "laptop" }; +const member = fakeSession(7, LAPTOP); + +const setup = async () => { + const world = await createSessionWorld({ sessions: [member] }); + const emit = vi.fn(async () => await Promise.resolve()); + world.app.use("*", async (c, next) => { + c.set("events", { emit } as unknown as Context["var"]["events"]); + await next(); + }); + world.app.openapi(updateMyTimeZoneRoute.route, updateMyTimeZoneRoute.handler); + + const save = async (timeZone: null | string, signedIn = true) => + await world.app.request("/me/time-zone", { + body: JSON.stringify({ timeZone }), + headers: { + "content-type": "application/json", + ...(signedIn ? { cookie: sessionCookies(member, "user") } : {}), + }, + method: "PUT", + }); + + const storedTimeZone = () => + world.rows(core_users).find(row => row.id === member.userId)?.timeZone; + + return { ...world, emit, save, storedTimeZone }; +}; + +describe("PUT /users/me/time-zone", () => { + it("saves a known time zone on the member's account", async () => { + const { emit, save, storedTimeZone } = await setup(); + + const response = await save("Europe/Warsaw"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ timeZone: "Europe/Warsaw" }); + expect(storedTimeZone()).toBe("Europe/Warsaw"); + expect(emit).toHaveBeenCalledWith( + "user.updated", + expect.objectContaining({ userId: member.userId }), + ); + }); + + it("clears it with null so the language's time zone applies again", async () => { + const { save, storedTimeZone } = await setup(); + await save("Europe/Warsaw"); + + expect((await save(null)).status).toBe(200); + expect(storedTimeZone()).toBeNull(); + }); + + it("refuses a time zone that doesn't exist", async () => { + const { save, storedTimeZone } = await setup(); + + expect((await save("Mars/Olympus_Mons")).status).toBe(400); + expect(storedTimeZone() ?? null).toBeNull(); + }); + + it("refuses visitors who aren't signed in", async () => { + const { save } = await setup(); + + expect((await save("Europe/Warsaw", false)).status).toBe(401); + }); +}); diff --git a/packages/vitnode/src/api/modules/users/routes/time-zone.route.ts b/packages/vitnode/src/api/modules/users/routes/time-zone.route.ts new file mode 100644 index 000000000..0f46332e0 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/routes/time-zone.route.ts @@ -0,0 +1,72 @@ +import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import { isValidTimeZone } from "@/api/lib/notifications/digest-period"; +import { buildRoute } from "@/api/lib/route"; +import { invalidateSessionCacheForUser } from "@/api/models/session-revoke"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_users } from "@/database/users"; + +export const updateMyTimeZoneRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "put", + description: + "Set the signed-in user's time zone. It decides when their daily and weekly digests arrive; `null` falls back to their language's time zone.", + path: "/me/time-zone", + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + timeZone: z + .string() + .max(64) + .nullable() + .openapi({ example: "Europe/Warsaw" }), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ timeZone: z.string().nullable() }), + }, + }, + description: "Saved time zone", + }, + 400: { description: "Unknown time zone" }, + 401: { description: "Unauthorized" }, + }, + }, + handler: async c => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { timeZone } = c.req.valid("json"); + if (timeZone !== null && !isValidTimeZone(timeZone)) { + throw new HTTPException(400, { message: "Unknown time zone." }); + } + + await c + .get("db") + .update(core_users) + .set({ timeZone }) + .where(eq(core_users.id, user.id)); + await invalidateSessionCacheForUser(c, user.id); + await c.get("events").emit("user.updated", { + email: user.email, + name: user.name, + userId: user.id, + }); + + return c.json({ timeZone }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/users/users.module.ts b/packages/vitnode/src/api/modules/users/users.module.ts index be7d1070c..a61576b3f 100644 --- a/packages/vitnode/src/api/modules/users/users.module.ts +++ b/packages/vitnode/src/api/modules/users/users.module.ts @@ -15,6 +15,7 @@ import { sessionRoute } from "./routes/session.route"; import { signInRoute } from "./routes/sign-in.route"; import { signOutRoute } from "./routes/sign-out.route"; import { signUpRoute } from "./routes/sign-up.route"; +import { updateMyTimeZoneRoute } from "./routes/time-zone.route"; import { updateMeRoute } from "./routes/update-me.route"; import { ssoUserModule } from "./sso/sso.module"; @@ -34,6 +35,7 @@ export const usersModule = buildModule({ profileRoute, mePolicyRoute, updateMeRoute, + updateMyTimeZoneRoute, ], modules: [ ssoUserModule, diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index 592f8eaca..98ecdf011 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -9,6 +9,7 @@ import { adminModule } from "./modules/admin/admin.module"; import { contentModule } from "./modules/content/content.module"; import { cronModule } from "./modules/cron/cron.module"; import { middlewareModule } from "./modules/middleware/middleware.module"; +import { notificationsModule } from "./modules/notifications/notifications.module"; import { pagesModule } from "./modules/pages/pages.module"; import { queueModule } from "./modules/queue/queue.module"; import { searchModule } from "./modules/search/search.module"; @@ -25,6 +26,7 @@ export const newBuildPluginApiCore = buildApiPlugin({ modules: [ middlewareModule, usersModule, + notificationsModule, adminModule, contentModule, cronModule, @@ -78,6 +80,11 @@ export const newBuildPluginApiCore = buildApiPlugin({ { permission: "can_delete", dependsOn: ["can_view"] }, ], queue: ["can_view"], + notifications: [ + "can_view", + { permission: "can_edit", dependsOn: ["can_view"] }, + { permission: "can_manage", dependsOn: ["can_view"] }, + ], cron: ["can_view", { permission: "can_run", dependsOn: ["can_view"] }], staff_moderators: [ "can_view", diff --git a/packages/vitnode/src/components/date-format.tsx b/packages/vitnode/src/components/date-format.tsx index 584e015b2..9baa79c7f 100644 --- a/packages/vitnode/src/components/date-format.tsx +++ b/packages/vitnode/src/components/date-format.tsx @@ -37,7 +37,9 @@ export const DateFormat = ({ if (now.getTime() - dateToFormat.getTime() < 604800000) { return ( - {format.relativeTime(dateToFormat, now)} + + {format.relativeTime(dateToFormat, now)} + ); } diff --git a/packages/vitnode/src/components/form/fields/editor.tsx b/packages/vitnode/src/components/form/fields/editor.tsx index 37931a70a..49aab5b6e 100644 --- a/packages/vitnode/src/components/form/fields/editor.tsx +++ b/packages/vitnode/src/components/form/fields/editor.tsx @@ -41,8 +41,14 @@ const MultiLangEditor = ({ > & { isOptional?: boolean; }) => { - const { languages, selected, setSelected, currentValue, setValue } = - useMultiLangField(field, { isFilled: hasHtmlText }); + const { + canSelect, + languages, + selected, + setSelected, + currentValue, + setValue, + } = useMultiLangField(field, { isFilled: hasHtmlText }); const labelledBy = useEditorLabelledBy(label); return ( @@ -53,7 +59,7 @@ const MultiLangEditor = ({ {label} )} - {languages.length > 1 && ( + {canSelect && ( & { isOptional?: boolean; }) => { - const { languages, selected, setSelected, currentValue, setValue } = - useMultiLangField(field); + const { + canSelect, + languages, + selected, + setSelected, + currentValue, + setValue, + } = useMultiLangField(field); const { maxLength, minLength } = getMultiLangConstraints(itemParams); return ( @@ -71,7 +77,7 @@ const MultiLangInput = ({ value={currentValue} /> - {languages.length > 1 && ( + {canSelect && ( ( + null, +); + +export const useMultiLangLanguage = (): null | string => + React.use(MultiLangLanguageContext); diff --git a/packages/vitnode/src/components/form/fields/multi-lang.test.tsx b/packages/vitnode/src/components/form/fields/multi-lang.test.tsx new file mode 100644 index 000000000..92562616c --- /dev/null +++ b/packages/vitnode/src/components/form/fields/multi-lang.test.tsx @@ -0,0 +1,80 @@ +import { act, renderHook } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import type { MultiLangValue } from "@/lib/helpers/multi-lang"; + +import { LanguagesProvider } from "@/components/languages-provider"; + +import { useMultiLangField } from "./multi-lang"; +import { MultiLangLanguageContext } from "./multi-lang-language"; + +const languages = [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, +]; + +const value: MultiLangValue = [ + { languageCode: "en", value: "Hello" }, + { languageCode: "pl", value: "Cześć" }, +]; + +const renderField = (lockedLanguage: null | string) => { + const onChange = vi.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + const { result } = renderHook( + () => + useMultiLangField({ + name: "title", + onBlur: vi.fn(), + onChange, + value, + }), + { wrapper }, + ); + + return { onChange, result }; +}; + +describe("useMultiLangField", () => { + it("lets the user pick a language when nothing locks it", () => { + const { result } = renderField(null); + + expect(result.current.canSelect).toBe(true); + expect(result.current.selected).toBe("en"); + expect(result.current.currentValue).toBe("Hello"); + }); + + it("edits the locked language and hides the picker", () => { + const { onChange, result } = renderField("pl"); + + expect(result.current.canSelect).toBe(false); + expect(result.current.selected).toBe("pl"); + expect(result.current.currentValue).toBe("Cześć"); + + act(() => { + result.current.setValue("Dzień dobry"); + }); + + expect(onChange).toHaveBeenCalledWith([ + { languageCode: "en", value: "Hello" }, + { languageCode: "pl", value: "Dzień dobry" }, + ]); + }); + + it("ignores a language picked before the lock", () => { + const { result } = renderField("pl"); + + act(() => { + result.current.setSelected("en"); + }); + + expect(result.current.selected).toBe("pl"); + }); +}); diff --git a/packages/vitnode/src/components/form/fields/multi-lang.tsx b/packages/vitnode/src/components/form/fields/multi-lang.tsx index e19d2cad7..cd62d33cf 100644 --- a/packages/vitnode/src/components/form/fields/multi-lang.tsx +++ b/packages/vitnode/src/components/form/fields/multi-lang.tsx @@ -20,6 +20,7 @@ import { } from "@/lib/helpers/multi-lang"; import { useMultiLangDefaultLanguage } from "./multi-lang-default-language"; +import { useMultiLangLanguage } from "./multi-lang-language"; export { multiLangValueSchema } from "@/lib/helpers/multi-lang"; export type { @@ -38,6 +39,7 @@ export const useMultiLangField = ( const languages = useLanguages(); const locale = useLocale(); const defaultLanguage = useMultiLangDefaultLanguage(); + const lockedLanguage = useMultiLangLanguage(); const { value } = field; const [selected, setSelected] = React.useState(() => pickLangCode({ @@ -49,15 +51,18 @@ export const useMultiLangField = ( }), ); + const language = lockedLanguage ?? selected; + const setValue = (newValue: string) => { - field.onChange(upsertLangValue(value, selected, newValue)); + field.onChange(upsertLangValue(value, language, newValue)); }; return { + canSelect: lockedLanguage === null && languages.length > 1, languages, - selected, + selected: language, setSelected, - currentValue: getLangValue(value, selected), + currentValue: getLangValue(value, language), setValue, }; }; diff --git a/packages/vitnode/src/components/form/fields/textarea.tsx b/packages/vitnode/src/components/form/fields/textarea.tsx index 881601526..4c270d674 100644 --- a/packages/vitnode/src/components/form/fields/textarea.tsx +++ b/packages/vitnode/src/components/form/fields/textarea.tsx @@ -37,8 +37,14 @@ const MultiLangTextarea = ({ > & { isOptional?: boolean; }) => { - const { languages, selected, setSelected, currentValue, setValue } = - useMultiLangField(field); + const { + canSelect, + languages, + selected, + setSelected, + currentValue, + setValue, + } = useMultiLangField(field); const { maxLength, minLength } = getMultiLangConstraints(itemParams); return ( @@ -49,7 +55,7 @@ const MultiLangTextarea = ({ {label} )} - {languages.length > 1 && ( + {canSelect && ( { + const shouldReduceMotion = useReducedMotion(); + + return ( + + {open ? ( + + {children} + + ) : null} + + ); +}; diff --git a/packages/vitnode/src/content/server/localized-admin-routes.test.ts b/packages/vitnode/src/content/server/localized-admin-routes.test.ts index d9cd8d7b2..1908a564e 100644 --- a/packages/vitnode/src/content/server/localized-admin-routes.test.ts +++ b/packages/vitnode/src/content/server/localized-admin-routes.test.ts @@ -42,6 +42,7 @@ const adminUser = { headline: null, phone: null, showRealName: false, + timeZone: null, firstName: null, lastName: null, avatarUrl: null, diff --git a/packages/vitnode/src/content/server/localized-list-routes.test.ts b/packages/vitnode/src/content/server/localized-list-routes.test.ts index 0a49db2e1..18a346dbe 100644 --- a/packages/vitnode/src/content/server/localized-list-routes.test.ts +++ b/packages/vitnode/src/content/server/localized-list-routes.test.ts @@ -41,6 +41,7 @@ const adminUser = { headline: null, phone: null, showRealName: false, + timeZone: null, firstName: null, lastName: null, avatarUrl: null, diff --git a/packages/vitnode/src/content/server/pagination-routes.test.ts b/packages/vitnode/src/content/server/pagination-routes.test.ts index 9b10d317a..39763b50c 100644 --- a/packages/vitnode/src/content/server/pagination-routes.test.ts +++ b/packages/vitnode/src/content/server/pagination-routes.test.ts @@ -28,6 +28,7 @@ const adminUser = { headline: null, phone: null, showRealName: false, + timeZone: null, firstName: null, lastName: null, avatarUrl: null, diff --git a/packages/vitnode/src/content/server/permission-matrix.test.ts b/packages/vitnode/src/content/server/permission-matrix.test.ts index 3ddfe2fa6..f34403758 100644 --- a/packages/vitnode/src/content/server/permission-matrix.test.ts +++ b/packages/vitnode/src/content/server/permission-matrix.test.ts @@ -83,6 +83,7 @@ const adminUser = { headline: null, phone: null, showRealName: false, + timeZone: null, firstName: null, lastName: null, avatarUrl: null, diff --git a/packages/vitnode/src/content/server/translation-editorial-routes.test.ts b/packages/vitnode/src/content/server/translation-editorial-routes.test.ts index be6c48056..25278f96f 100644 --- a/packages/vitnode/src/content/server/translation-editorial-routes.test.ts +++ b/packages/vitnode/src/content/server/translation-editorial-routes.test.ts @@ -49,6 +49,7 @@ const adminUser = { headline: null, phone: null, showRealName: false, + timeZone: null, firstName: null, lastName: null, avatarUrl: null, diff --git a/packages/vitnode/src/content/server/translation-publication-routes.test.ts b/packages/vitnode/src/content/server/translation-publication-routes.test.ts index ee709016d..e5463bdb2 100644 --- a/packages/vitnode/src/content/server/translation-publication-routes.test.ts +++ b/packages/vitnode/src/content/server/translation-publication-routes.test.ts @@ -27,6 +27,7 @@ const adminUser = { headline: null, phone: null, showRealName: false, + timeZone: null, firstName: null, lastName: null, avatarUrl: null, diff --git a/packages/vitnode/src/content/server/translation-routes.test.ts b/packages/vitnode/src/content/server/translation-routes.test.ts index 586457b8b..f5da82ce7 100644 --- a/packages/vitnode/src/content/server/translation-routes.test.ts +++ b/packages/vitnode/src/content/server/translation-routes.test.ts @@ -56,6 +56,7 @@ const adminUser = { headline: null, phone: null, showRealName: false, + timeZone: null, firstName: null, lastName: null, avatarUrl: null, diff --git a/packages/vitnode/src/database/notifications.ts b/packages/vitnode/src/database/notifications.ts new file mode 100644 index 000000000..ca757b224 --- /dev/null +++ b/packages/vitnode/src/database/notifications.ts @@ -0,0 +1,209 @@ +import { sql } from "drizzle-orm"; +import { camelCase, index, primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; + +import type { NotificationEmailMode } from "@/lib/notifications/types"; + +import { core_users } from "./users"; + +export const core_notification_events = camelCase.table.withRLS( + "core_notification_events", + t => ({ + id: t.bigserial({ mode: "number" }).primaryKey(), + pluginId: t.varchar({ length: 100 }).notNull(), + type: t.varchar({ length: 100 }).notNull(), + schemaVersion: t.integer().notNull().default(1), + idempotencyKey: t.varchar({ length: 255 }).notNull(), + actorId: t.integer().references(() => core_users.id, { + onDelete: "set null", + }), + subjectType: t.varchar({ length: 100 }), + subjectId: t.varchar({ length: 100 }), + groupKey: t.varchar({ length: 255 }), + data: t.jsonb().$type>().notNull().default({}), + recipientIds: t + .integer() + .array() + .notNull() + .default(sql`'{}'::integer[]`), + allowSelf: t.boolean().notNull().default(false), + status: t + .varchar({ + enum: ["pending", "processing", "completed", "failed"], + length: 20, + }) + .notNull() + .default("pending"), + recipientCursor: t.integer().notNull().default(0), + deliveredCount: t.integer().notNull().default(0), + lastError: t.text(), + createdAt: t.timestamp().notNull().defaultNow(), + completedAt: t.timestamp(), + }), + t => [ + uniqueIndex("core_notification_events_idempotency_unique").on( + t.pluginId, + t.type, + t.idempotencyKey, + ), + index("core_notification_events_status_idx").on(t.status, t.id), + index("core_notification_events_created_at_idx").on(t.createdAt), + ], +); + +export const core_notifications = camelCase.table.withRLS( + "core_notifications", + t => ({ + id: t.bigserial({ mode: "number" }).primaryKey(), + userId: t + .integer() + .references(() => core_users.id, { onDelete: "cascade" }) + .notNull(), + pluginId: t.varchar({ length: 100 }).notNull(), + type: t.varchar({ length: 100 }).notNull(), + category: t.varchar({ length: 50 }).notNull(), + groupKey: t.varchar({ length: 255 }).notNull(), + groupBucket: t.bigint({ mode: "number" }).notNull().default(0), + subjectType: t.varchar({ length: 100 }), + subjectId: t.varchar({ length: 100 }), + latestEventId: t + .bigint({ mode: "number" }) + .references(() => core_notification_events.id, { onDelete: "restrict" }) + .notNull(), + eventCount: t.integer().notNull().default(1), + activitySeq: t.integer().notNull().default(1), + readSeq: t.integer().notNull().default(0), + readAt: t.timestamp(), + archivedAt: t.timestamp(), + createdAt: t.timestamp().notNull().defaultNow(), + lastActivityAt: t.timestamp().notNull().defaultNow(), + }), + t => [ + uniqueIndex("core_notifications_group_unique").on( + t.userId, + t.pluginId, + t.type, + t.groupKey, + t.groupBucket, + ), + index("core_notifications_inbox_idx") + .on(t.userId, t.lastActivityAt.desc(), t.id.desc()) + .where(sql`"archivedAt" IS NULL`), + index("core_notifications_unread_idx") + .on(t.userId, t.lastActivityAt.desc(), t.id.desc()) + .where(sql`"archivedAt" IS NULL AND "readSeq" < "activitySeq"`), + index("core_notifications_subject_idx").on(t.subjectType, t.subjectId), + index("core_notifications_last_activity_idx").on(t.lastActivityAt), + index("core_notifications_latest_event_idx").on(t.latestEventId), + ], +); + +export const core_notification_receipts = camelCase.table.withRLS( + "core_notification_receipts", + t => ({ + eventId: t + .bigint({ mode: "number" }) + .references(() => core_notification_events.id, { onDelete: "cascade" }) + .notNull(), + userId: t + .integer() + .references(() => core_users.id, { onDelete: "cascade" }) + .notNull(), + notificationId: t + .bigint({ mode: "number" }) + .references(() => core_notifications.id, { onDelete: "cascade" }), + seq: t.integer(), + emailPending: t.boolean().notNull().default(false), + emailDeliveryId: t.bigint({ mode: "number" }), + createdAt: t.timestamp().notNull().defaultNow(), + }), + t => [ + primaryKey({ columns: [t.eventId, t.userId] }), + index("core_notification_receipts_notification_idx").on( + t.notificationId, + t.seq, + ), + index("core_notification_receipts_user_idx").on(t.userId), + index("core_notification_receipts_email_pending_idx") + .on(t.userId, t.createdAt) + .where(sql`"emailPending" = true AND "emailDeliveryId" IS NULL`), + ], +); + +export interface NotificationTypePreference { + email?: NotificationEmailMode; + inApp?: boolean; + push?: boolean; +} + +export const core_notification_user_state = camelCase.table.withRLS( + "core_notification_user_state", + t => ({ + userId: t + .integer() + .primaryKey() + .references(() => core_users.id, { onDelete: "cascade" }), + unreadCount: t.integer().notNull().default(0), + revision: t.bigint({ mode: "number" }).notNull().default(0), + preferences: t + .jsonb() + .$type>() + .notNull() + .default({}), + updatedAt: t.timestamp().notNull().defaultNow(), + }), + t => [index("core_notification_user_state_unread_idx").on(t.unreadCount)], +); + +export const core_notification_deliveries = camelCase.table.withRLS( + "core_notification_deliveries", + t => ({ + id: t.bigserial({ mode: "number" }).primaryKey(), + userId: t + .integer() + .references(() => core_users.id, { onDelete: "cascade" }) + .notNull(), + channel: t.varchar({ enum: ["email"], length: 20 }).notNull(), + mode: t + .varchar({ enum: ["immediate", "daily", "weekly", "test"], length: 20 }) + .notNull(), + eventId: t + .bigint({ mode: "number" }) + .references(() => core_notification_events.id, { onDelete: "set null" }), + idempotencyKey: t.varchar({ length: 255 }).notNull().unique(), + status: t + .varchar({ + enum: ["pending", "sending", "sent", "failed", "skipped"], + length: 20, + }) + .notNull() + .default("pending"), + attempts: t.integer().notNull().default(0), + maxAttempts: t.integer().notNull().default(5), + availableAt: t.timestamp().notNull().defaultNow(), + periodStart: t.timestamp(), + periodEnd: t.timestamp(), + itemCount: t.integer().notNull().default(0), + providerMessageId: t.varchar({ length: 255 }), + lastError: t.varchar({ length: 500 }), + skipReason: t.varchar({ length: 50 }), + createdAt: t.timestamp().notNull().defaultNow(), + updatedAt: t.timestamp().notNull().defaultNow(), + sentAt: t.timestamp(), + }), + t => [ + index("core_notification_deliveries_claim_idx") + .on(t.availableAt, t.id) + .where(sql`status = 'pending'`), + index("core_notification_deliveries_status_idx").on(t.status, t.id), + index("core_notification_deliveries_user_idx").on(t.userId), + ], +); + +export const core_notification_settings = camelCase.table.withRLS( + "core_notification_settings", + t => ({ + key: t.varchar({ length: 255 }).primaryKey(), + value: t.jsonb().$type>().notNull().default({}), + updatedAt: t.timestamp().notNull().defaultNow(), + }), +); diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts index b0397dbdc..1482e3c0c 100644 --- a/packages/vitnode/src/database/relations.ts +++ b/packages/vitnode/src/database/relations.ts @@ -9,6 +9,7 @@ import * as languages from "./languages"; import * as logs from "./logs"; import * as moderators from "./moderators"; import * as navigation from "./navigation"; +import * as notifications from "./notifications"; import * as passkeys from "./passkeys"; import * as queue from "./queue"; import * as roles from "./roles"; @@ -27,6 +28,7 @@ export const coreSchema = { ...logs, ...moderators, ...navigation, + ...notifications, ...passkeys, ...queue, ...roles, diff --git a/packages/vitnode/src/database/users.ts b/packages/vitnode/src/database/users.ts index bb2bbfc39..a1791a104 100644 --- a/packages/vitnode/src/database/users.ts +++ b/packages/vitnode/src/database/users.ts @@ -45,6 +45,7 @@ export const core_users = camelCase.table.withRLS( .references(() => core_roles.id) .notNull(), birthday: t.timestamp(), + timeZone: t.varchar({ length: 64 }), ipAddress: t.varchar({ length: 40 }).notNull(), language: t .varchar({ length: 32 }) diff --git a/packages/vitnode/src/emails/notification.tsx b/packages/vitnode/src/emails/notification.tsx new file mode 100644 index 000000000..4519c0c1a --- /dev/null +++ b/packages/vitnode/src/emails/notification.tsx @@ -0,0 +1,85 @@ +import { Heading, Hr, Link, Section, Text } from "react-email"; + +import DefaultTemplateEmail, { + type DefaultTemplateEmailProps, +} from "./default-template"; +import { EmailButton } from "./ui/button"; + +export interface NotificationEmailEntry { + body?: string; + title: string; + url: null | string; +} + +export interface NotificationEmailProps extends DefaultTemplateEmailProps { + actionLabel: string; + entries: NotificationEmailEntry[]; + heading: string; + intro?: string; + preferencesLabel: string; + preferencesUrl: string; +} + +export default function NotificationEmailTemplate({ + actionLabel, + entries, + heading, + intro, + preferencesLabel, + preferencesUrl, + ...props +}: NotificationEmailProps) { + return ( + +

+ + {heading} + + {intro ? {intro} : null} + + {entries.map((entry, index) => ( + // eslint-disable-next-line @eslint-react/no-array-index-key +
+ {index > 0 ?
: null} + {entries.length > 1 ? ( + {entry.title} + ) : null} + {entry.body ? ( + {entry.body} + ) : null} + {entry.url ? ( + entries.length === 1 ? ( + {actionLabel} + ) : ( + + {actionLabel} + + ) + ) : null} +
+ ))} +
+ + + + {preferencesLabel} + + + + ); +} + +NotificationEmailTemplate.PreviewProps = { + ...DefaultTemplateEmail.PreviewProps, + actionLabel: "View", + entries: [ + { + body: "A new article was published in a category you follow.", + title: "New article: Hello world", + url: "https://example.com/blog/hello-world", + }, + ], + heading: "New article: Hello world", + preferencesLabel: "Manage notification preferences", + preferencesUrl: "https://example.com/settings/notifications", +} satisfies NotificationEmailProps; diff --git a/packages/vitnode/src/lib/api/cron-health.test.ts b/packages/vitnode/src/lib/api/cron-health.test.ts new file mode 100644 index 000000000..e8b4cf46a --- /dev/null +++ b/packages/vitnode/src/lib/api/cron-health.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { getCronHealth, isCronJobOverdue } from "./cron-health"; + +const NOW = new Date("2026-10-04T15:35:00Z"); + +const job = (overrides: { + createdAt?: string; + lastRun?: null | string; + schedule?: string; +}) => ({ + createdAt: new Date(overrides.createdAt ?? "2026-01-01T00:00:00Z"), + lastRun: overrides.lastRun ? new Date(overrides.lastRun) : null, + schedule: overrides.schedule ?? "* * * * *", +}); + +describe("isCronJobOverdue", () => { + it("is on time when the last run is within the schedule", () => { + expect( + isCronJobOverdue(job({ lastRun: "2026-10-04T15:34:00Z" }), NOW), + ).toBe(false); + }); + + it("tolerates a missed tick inside the grace period", () => { + expect( + isCronJobOverdue(job({ lastRun: "2026-10-04T15:25:00Z" }), NOW), + ).toBe(false); + }); + + it("is overdue once the expected run is past the grace period", () => { + expect( + isCronJobOverdue(job({ lastRun: "2026-10-04T15:00:00Z" }), NOW), + ).toBe(true); + }); + + it("measures a job that never ran from its registration", () => { + expect( + isCronJobOverdue( + job({ createdAt: "2026-10-04T09:51:00Z", schedule: "*/5 * * * *" }), + NOW, + ), + ).toBe(true); + expect( + isCronJobOverdue( + job({ createdAt: "2026-10-04T15:30:00Z", schedule: "*/5 * * * *" }), + NOW, + ), + ).toBe(false); + }); + + it("does not flag a daily job before its next slot", () => { + expect( + isCronJobOverdue( + job({ lastRun: "2026-10-04T03:20:00Z", schedule: "20 3 * * *" }), + NOW, + ), + ).toBe(false); + }); +}); + +describe("getCronHealth", () => { + it("reports a healthy scheduler", () => { + expect( + getCronHealth( + [ + job({ lastRun: "2026-10-04T15:34:00Z" }), + job({ lastRun: "2026-10-04T15:00:00Z", schedule: "0 * * * *" }), + ], + NOW, + ), + ).toEqual({ + jobs: 2, + lastRun: new Date("2026-10-04T15:34:00Z"), + nextRun: new Date("2026-10-04T15:35:00Z"), + overdueJobs: 0, + stale: false, + }); + }); + + it("is stale when nothing ran since a job came due, even if a job was just registered", () => { + expect( + getCronHealth( + [ + job({ lastRun: "2026-09-06T16:41:00Z" }), + job({ lastRun: "2026-09-06T16:00:00Z", schedule: "0 * * * *" }), + job({ createdAt: "2026-10-04T09:51:00Z", schedule: "*/5 * * * *" }), + ], + NOW, + ), + ).toEqual({ + jobs: 3, + lastRun: new Date("2026-09-06T16:41:00Z"), + nextRun: null, + overdueJobs: 3, + stale: true, + }); + }); + + it("is stale on an install where no job has ever run", () => { + expect( + getCronHealth([job({ createdAt: "2026-10-04T12:00:00Z" })], NOW), + ).toMatchObject({ lastRun: null, overdueJobs: 1, stale: true }); + }); + + it("separates one failing job from a stopped scheduler", () => { + expect( + getCronHealth( + [ + job({ lastRun: "2026-10-04T15:34:00Z" }), + job({ lastRun: "2026-10-04T12:00:00Z", schedule: "0 * * * *" }), + ], + NOW, + ), + ).toMatchObject({ overdueJobs: 1, stale: false }); + }); + + it("points at the earliest upcoming run, skipping overdue jobs", () => { + expect( + getCronHealth( + [ + job({ lastRun: "2026-10-04T12:00:00Z", schedule: "0 * * * *" }), + job({ lastRun: "2026-10-04T15:30:00Z", schedule: "*/10 * * * *" }), + job({ lastRun: "2026-10-04T03:20:00Z", schedule: "20 3 * * *" }), + ], + NOW, + ).nextRun, + ).toEqual(new Date("2026-10-04T15:40:00Z")); + }); + + it("is healthy without any registered job", () => { + expect(getCronHealth([], NOW)).toEqual({ + jobs: 0, + lastRun: null, + nextRun: null, + overdueJobs: 0, + stale: false, + }); + }); +}); diff --git a/packages/vitnode/src/lib/api/cron-health.ts b/packages/vitnode/src/lib/api/cron-health.ts new file mode 100644 index 000000000..4c2492d06 --- /dev/null +++ b/packages/vitnode/src/lib/api/cron-health.ts @@ -0,0 +1,58 @@ +import { getNextCronRunDate } from "./get-next-cron-run-date"; + +const CRON_OVERDUE_GRACE_MS = 15 * 60 * 1000; + +export interface CronJobTiming { + createdAt: Date; + lastRun: Date | null; + schedule: string; +} + +export interface CronHealth { + jobs: number; + lastRun: Date | null; + nextRun: Date | null; + overdueJobs: number; + stale: boolean; +} + +const getCronJobDueAt = (job: CronJobTiming): Date | null => + getNextCronRunDate(job.schedule, job.lastRun ?? job.createdAt); + +const isPastGrace = (dueAt: Date | null, now: Date): dueAt is Date => + !!dueAt && now.getTime() - dueAt.getTime() > CRON_OVERDUE_GRACE_MS; + +const earliestOf = (dates: Date[]): Date | null => + dates.reduce( + (earliest, date) => (!earliest || date < earliest ? date : earliest), + null, + ); + +export const isCronJobOverdue = ( + job: CronJobTiming, + now: Date = new Date(), +): boolean => isPastGrace(getCronJobDueAt(job), now); + +export const getCronHealth = ( + jobs: CronJobTiming[], + now: Date = new Date(), +): CronHealth => { + const lastRun = jobs.reduce( + (latest, job) => + job.lastRun && (!latest || job.lastRun > latest) ? job.lastRun : latest, + null, + ); + const dueDates = jobs + .map(getCronJobDueAt) + .filter((dueAt): dueAt is Date => !!dueAt); + const overdueSince = dueDates.filter(dueAt => isPastGrace(dueAt, now)); + const earliestOverdue = earliestOf(overdueSince); + + return { + jobs: jobs.length, + lastRun, + nextRun: earliestOf(dueDates.filter(dueAt => !isPastGrace(dueAt, now))), + overdueJobs: overdueSince.length, + stale: !!earliestOverdue && (!lastRun || lastRun < earliestOverdue), + }; +}; diff --git a/packages/vitnode/src/lib/api/is-cron-stale.ts b/packages/vitnode/src/lib/api/is-cron-stale.ts deleted file mode 100644 index e2c1060d8..000000000 --- a/packages/vitnode/src/lib/api/is-cron-stale.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Cron is considered stale when no job has run within this window. Past it the -// scheduler is likely misconfigured or stopped, which also means the queue -// worker isn't draining tasks. -export const CRON_STALE_THRESHOLD_MS = 6 * 60 * 60 * 1000; - -export const isCronStale = ( - lastActivityAt: Date | null, - now: Date = new Date(), -): boolean => { - if (!lastActivityAt) return false; - - return now.getTime() - lastActivityAt.getTime() > CRON_STALE_THRESHOLD_MS; -}; diff --git a/packages/vitnode/src/lib/config.ts b/packages/vitnode/src/lib/config.ts index ed3eaf0d9..3ec341d9d 100644 --- a/packages/vitnode/src/lib/config.ts +++ b/packages/vitnode/src/lib/config.ts @@ -40,3 +40,7 @@ export const CONFIG = { return new URL(process.env.VITNODE_WEB_URL ?? "http://localhost:3000"); }, }; + +export const isCronSecretRejected = (secret: string | undefined): boolean => + !secret || + (INSECURE_CRON_SECRETS.includes(secret) && !CONFIG.node_development); diff --git a/packages/vitnode/src/lib/notifications/types.ts b/packages/vitnode/src/lib/notifications/types.ts new file mode 100644 index 000000000..e88e36736 --- /dev/null +++ b/packages/vitnode/src/lib/notifications/types.ts @@ -0,0 +1,32 @@ +export const NOTIFICATION_EMAIL_MODES = [ + "none", + "immediate", + "daily", + "weekly", +] as const; + +export type NotificationEmailMode = (typeof NOTIFICATION_EMAIL_MODES)[number]; + +export type NotificationStateReason = + | "archived" + | "created" + | "read" + | "read_all" + | "reconciled" + | "removed" + | "unread"; + +export interface NotificationState { + revision: number; + unread: number; +} + +export interface NotificationStateMessage extends NotificationState { + notificationId?: number; + reason: NotificationStateReason; +} + +export interface NotificationSubject { + id: number | string; + type: string; +} diff --git a/packages/vitnode/src/locales/api/en.json b/packages/vitnode/src/locales/api/en.json index f487a3fff..e9a84b46a 100644 --- a/packages/vitnode/src/locales/api/en.json +++ b/packages/vitnode/src/locales/api/en.json @@ -14,6 +14,33 @@ "expire_time": "This link will expire on {date}" } } + }, + "notifications": { + "unavailable": "This notification is no longer available.", + "categories": { + "account": "Account", + "content": "Content", + "social": "Social", + "system": "System" + }, + "types": { + "admin_message": { + "label": "Messages from administrators", + "description": "Announcements an administrator sends you directly." + } + }, + "email": { + "action": "View", + "preferences": "Manage notification preferences", + "digest_subject_daily": "Your daily summary: {count, plural, one {# notification} other {# notifications}}", + "digest_subject_weekly": "Your weekly summary: {count, plural, one {# notification} other {# notifications}}", + "digest_heading_daily": "Here's what happened today", + "digest_heading_weekly": "Here's what happened this week", + "digest_more": "And {count, plural, one {# more notification} other {# more notifications}} waiting for you.", + "test_subject": "Test notification email", + "test_title": "Notification emails work", + "test_body": "If you can read this, notification emails from this site reach your inbox. No action needed." + } } } } diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 644256298..922b5eec0 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -36,6 +36,10 @@ "@vitnode/core:navigation:can_delete": "Remove main menu items", "@vitnode/core:queue": "Queue Tasks", "@vitnode/core:queue:can_view": "View queue tasks", + "@vitnode/core:notifications": "Notifications", + "@vitnode/core:notifications:can_view": "View notification settings and delivery health", + "@vitnode/core:notifications:can_edit": "Edit notification settings", + "@vitnode/core:notifications:can_manage": "Test emails, cleanup and the danger zone: pause, cancel queued emails, mark everything read, delete all and reset member preferences", "@vitnode/core:cron": "Cron Jobs", "@vitnode/core:cron:can_view": "View cron jobs", "@vitnode/core:cron:can_run": "Run cron jobs", @@ -523,6 +527,28 @@ "showing_range": "Showing {from}–{to} of {total}", "rows_per_page": "Rows per page", "page_of": "Page {page} of {total}", + "notifications": { + "title": "Notifications", + "bell_label": "Notifications", + "bell_label_unread": "Notifications, {count, plural, one {# unread} other {# unread}}", + "recent": "Recent notifications", + "mark_all_read": "Mark all as read", + "all_read": "All caught up", + "all_read_desc": "Every notification is marked as read.", + "mark_read": "Mark as read", + "mark_unread": "Mark as unread", + "archive": "Archive", + "archived": "Notification archived", + "unread": "Unread", + "loading": "Loading notifications…", + "load_error": "We couldn't load your notifications.", + "retry": "Try again", + "empty_title": "You're all caught up", + "empty_desc": "New activity will show up here.", + "view_all": "View all notifications", + "settings": "Notification settings", + "action_failed": "That didn't work. Your notifications were refreshed." + }, "tab_has_errors": "Has errors" }, "navigation": { @@ -804,7 +830,8 @@ "overview": "Overview", "devices": "Devices", "security": "Security", - "sso": "Connected accounts" + "sso": "Connected accounts", + "notifications": "Notifications" }, "overview": { "desc": "Your avatar and the details on your account.", @@ -827,7 +854,15 @@ "notSet": "Not set", "save": "Save changes", "saved": "Personal information updated.", - "savedDesc": "Your profile shows the new details right away." + "savedDesc": "Your profile shows the new details right away.", + "regionTitle": "Region", + "regionDesc": "Digest emails and dates follow this time zone.", + "timeZone": "Time zone", + "timeZoneDesc": "Search by city, like Warsaw or New York.", + "timeZoneAuto": "Automatic - from your language", + "timeZoneUseDevice": "Use this device's time zone ({timeZone})", + "timeZoneSaved": "Time zone saved", + "timeZoneSavedDesc": "Digests and dates now follow {timeZone}." }, "devices": { "title": "Devices", @@ -1069,6 +1104,68 @@ "saved_from": "{field} now follows {provider}", "saved_manual": "{field} is now manual" } + }, + "notifications": { + "title": "Notifications", + "desc": "Choose what reaches you and where - in your notifications, as push, or by email.", + "saved": "Saved", + "saved_desc": "Your notification settings are up to date.", + "locked": "Set by the admin", + "channels": { + "title": "Where notifications reach you", + "push": { + "title": "Push", + "desc": "Sent to this device even when you're not browsing.", + "enable": "Turn on push in this browser", + "enabled": "On", + "denied": "Blocked by your browser. Allow notifications for this site in its settings to turn push on.", + "unsupported": "This browser can't receive push notifications.", + "soon": "Push delivery is on its way - the choices below are saved for when it lands.", + "enabled_toast": "Push is on", + "enabled_toast_desc": "This browser can now get your notifications.", + "enable_short": "Turn on" + }, + "email": { + "title": "Email", + "desc": "Sent to .", + "stop_title": "Stop all email notifications?", + "stop_desc": "You won't get notification emails or digests any more. Security alerts still arrive by email - they keep your account safe. Your notification list isn't affected.", + "stop_cancel": "Keep emails", + "stop_submit": "Stop all emails", + "stopped_toast_desc": "No more notification emails. Security alerts still reach you.", + "restored_toast_desc": "Your emails are back the way they were.", + "desc_off": "Off - only security alerts go to .", + "unavailable": "None of your notifications are sent by email right now.", + "on_toast_desc": "Notification emails are back on." + } + }, + "types": { + "title": "What you get notified about", + "empty": "There's nothing to set up yet.", + "summary": { + "list": "Notification list", + "push": "Push", + "email": "Email", + "on": "on", + "off": "off", + "unavailable": "not available" + }, + "mandatory": "A security alert - it always reaches you.", + "locked": "Set by the admin, so it can't be changed here.", + "list_desc": "Show it under the bell when you visit.", + "push_desc": "Send it to devices where you turned push on.", + "email_off": "No emails for this one.", + "frequency_label": "How often to email {type}", + "email_immediate": "Emailed right away.", + "email_daily": "Goes into your daily digest.", + "email_weekly": "Goes into your weekly digest." + }, + "email_modes": { + "none": "Off", + "immediate": "Right away", + "daily": "Daily digest", + "weekly": "Weekly digest" + } } } }, @@ -1502,6 +1599,24 @@ }, "full": "This area already holds as many widgets as it can." } + }, + "notifications": { + "title": "Notifications", + "desc": "Everything that happened while you were away. Opening this page never marks anything as read.", + "list_label": "Notifications", + "filter_all": "All", + "filter_unread": "Unread", + "all_categories": "All categories", + "category_label": "Filter by category", + "mark_all_read": "Mark all as read", + "settings": "Notification settings", + "loading": "Loading…", + "load_more": "Load more", + "load_error": "We couldn't load your notifications. Check your connection and try again.", + "retry": "Try again", + "empty_title": "Nothing here yet", + "empty_unread_title": "No unread notifications", + "empty_desc": "When something needs your attention, it lands here." } }, "admin": { @@ -1618,7 +1733,8 @@ "title": "System", "integrations": "Integrations", "navigation": "Navigation", - "files": "Files" + "files": "Files", + "notifications": "Notifications" }, "advanced": { "title": "Advanced", @@ -1650,8 +1766,13 @@ "desc": "Manage and monitor scheduled tasks.", "list": { "name": "Name", - "pluginId": "Plugin ID", - "module": "Module", + "status": { + "title": "Status", + "on_schedule": "On schedule", + "overdue": "Overdue", + "waiting": "Waiting for first run" + }, + "plugin": "Plugin", "schedule": "Schedule", "lastRun": { "title": "Last Run", @@ -1666,7 +1787,32 @@ "label": "Run Job Now", "success": "Cron job executed successfully." } - } + }, + "search": "Search jobs…" + }, + "health": { + "stale": { + "title": "Scheduler stopped", + "desc": "Nothing has run since . The queue, scheduled notifications and cleanups are on hold until the scheduler is back.", + "desc_never": "No job has run yet. The queue, scheduled notifications and cleanups are on hold until the scheduler starts.", + "help": "Check your cron setup and the server logs." + }, + "overdue": { + "title": "{count, plural, one {# cron job is} other {# cron jobs are}} overdue", + "desc": "The scheduler is running, but some jobs haven't finished on time. A job that throws keeps its last successful run, so look for its error in the server logs." + }, + "ok": { + "title": "All jobs on schedule", + "desc": "Every job ran at its last scheduled time." + }, + "no_adapter": "No cron adapter is configured, so jobs only run when an external scheduler calls the cron endpoint.", + "facts": { + "last_run": "Last run", + "next_run": "Next up", + "jobs": "Jobs", + "never": "Never" + }, + "secret_rejected": "The cron endpoint refuses every run because CRON_SECRET is missing or still a built-in default. Set CRON_SECRET to a random value in your API environment and restart it." } }, "queue": { @@ -1765,6 +1911,156 @@ "uploaded": "Cover image updated.", "removed": "Cover image removed." } + }, + "verify": { + "action": "Mark email as verified", + "success": "Email marked as verified", + "successDesc": "{name} can now receive emails from the site." + }, + "badges": { + "staff": "Staff", + "verified": "Verified", + "unverified": "Unverified" + }, + "tabs": { + "profileLabel": "Member profile", + "detailsLabel": "Member details", + "activity": "Activity", + "notifications": "Notifications", + "security": "Password & passkeys", + "securityShort": "Security" + }, + "personal": { + "title": "Personal information", + "edit": "Edit personal information", + "editDesc": "Optional details from the member's profile. Empty fields are cleared.", + "save": "Save changes", + "saved": "Personal information saved", + "savedDesc": "{name}'s profile shows the new details.", + "firstName": "First name", + "lastName": "Last name", + "headline": "Headline", + "headlineDesc": "Shown under the name on the profile. Up to {max} characters.", + "phone": "Phone", + "birthday": "Birthday", + "showRealName": "Show real name", + "showRealNameDesc": "Shows first and last name next to the display name.", + "memberId": "Member ID", + "realName": "Real name", + "hidden": "hidden", + "notSet": "Not set" + }, + "preferences": { + "title": "Preferences", + "edit": "Edit preferences", + "editDesc": "Language, time zone and newsletter for this member.", + "save": "Save changes", + "saved": "Preferences saved", + "savedDesc": "{name} sees the change on their next page load.", + "language": "Language", + "timeZone": "Time zone", + "timeZoneDesc": "Used for digest emails and dates. Automatic follows the language.", + "timeZoneAuto": "Automatic", + "newsletter": "Newsletter", + "newsletterDesc": "Receives newsletter emails sent from the AdminCP.", + "subscribed": "Subscribed", + "notSubscribed": "Not subscribed" + }, + "sso": { + "title": "Connected accounts", + "edit": "Edit profile source and sync", + "editTitle": "Profile source and sync", + "editDesc": "Choose where {name}'s avatar and name come from, and whether they refresh each time they sign in.", + "syncTitle": "Sync on sign-in", + "syncFooter": "Sync refreshes the fields above from that account each time they sign in with it.", + "syncNoFields": "Not a source for any field yet", + "syncOn": "{provider} syncs on sign-in", + "syncOff": "{provider} sync turned off", + "disconnect": "Disconnect {provider}", + "disconnectTitle": "Disconnect {provider}?", + "disconnectDesc": "{name} will no longer be able to sign in with {provider}. Their password and other connections stay as they are.", + "disconnectSubmit": "Disconnect", + "disconnected": "{provider} disconnected", + "disconnectedDesc": "{name} can't sign in with it any more.", + "connected": "Connected", + "sourceBadge": "{fields} from here", + "syncsBadge": "Syncs on sign-in", + "lastMethod": "Can't remove the last way to sign in", + "lastMethodDesc": "Set a password or add another sign-in method first.", + "loadError": "Connected accounts couldn't be loaded. Refresh to try again.", + "empty": "No connected accounts. This member signs in with their password or a passkey." + }, + "devices": { + "title": "Devices", + "deviceName": "{browser} on {os}", + "unknownDevice": "Unknown device", + "signOut": "Sign out this device", + "signOutLabel": "Sign out {device}", + "signOutTitle": "Sign out this device?", + "signOutDesc": "{name} is signed out on {device}, including any AdminCP session there.", + "signOutSubmit": "Sign out", + "signedOut": "Device signed out", + "signedOutDesc": "{device} needs to sign in again.", + "signOutAll": "Sign out all", + "signOutAllTitle": "Sign {name} out everywhere?", + "signOutAllDesc": "{count, plural, one {The session on # device ends} other {Every session on all # devices ends}}, including AdminCP sessions. They can sign in again right away.", + "signedOutAll": "Signed out everywhere", + "signedOutAllDesc": "{name} needs to sign in again on every device.", + "admincp": "AdminCP", + "showAll": "Show all {count} devices", + "showFewer": "Show fewer", + "loadError": "Devices couldn't be loaded. Refresh to try again.", + "empty": "Not signed in on any device." + }, + "security": { + "title": "Password & passkeys", + "password": "Password", + "passwordSet": "Password set", + "passwordNotSet": "No password", + "passwordDisabled": "Password sign-in is turned off on this site.", + "passwordLoadError": "The password status couldn't be loaded. Refresh to try again.", + "passwordChange": "Change", + "passwordAdd": "Set password", + "passwordTitle": "Set a new password", + "passwordDesc": "{name} is signed out of every device and must use the new password next time.", + "newPassword": "New password", + "confirmPassword": "Confirm password", + "passwordMin": "Use at least {min} characters.", + "passwordMismatch": "Passwords don't match.", + "passwordSubmit": "Set password", + "passwordSaved": "Password changed", + "passwordSavedDesc": "{name} was signed out everywhere.", + "passkeys": "Passkeys", + "passkeysEmpty": "No passkeys yet.", + "passkeysDisabled": "Passkeys are turned off on this site.", + "passkeysLoadError": "Passkeys couldn't be loaded. Refresh to try again.", + "passkeySynced": "Synced", + "passkeyDeviceOnly": "This device only", + "passkeyUsed": "Used", + "passkeyNeverUsed": "Never used", + "passkeyRename": "Rename passkey", + "passkeyRenameLabel": "Rename {name}", + "passkeyRenameTitle": "Rename passkey", + "passkeyRenameDesc": "Only the label changes; the passkey keeps working.", + "passkeyName": "Name", + "passkeyRenameSubmit": "Save name", + "passkeyRenamed": "Passkey renamed", + "passkeyDelete": "Delete passkey", + "passkeyDeleteLabel": "Delete {name}", + "passkeyDeleteTitle": "Delete this passkey?", + "passkeyDeleteDesc": "{name} won't be able to sign in with “{passkey}” any more.", + "passkeyDeleteSubmit": "Delete passkey", + "passkeyDeleted": "Passkey deleted", + "passkeyDeletedDesc": "“{passkey}” no longer signs in to this account.", + "lastMethod": "Can't remove the last way to sign in", + "lastMethodDesc": "Set a password or connect another sign-in method first." + }, + "notifications": { + "desc": "Each type follows the site's notification settings until you change it here. Changes apply to {name} only.", + "empty": "No notification types are turned on for this site.", + "saved": "Notification preference saved", + "savedDesc": "Applies to {name} only.", + "loadError": "Notification preferences couldn't be loaded. Refresh to try again." } }, "verify_email": { @@ -1959,6 +2255,201 @@ } }, "system": { + "notifications": { + "title": "Notifications", + "desc": "See how notifications are doing, decide what members get by default, and tidy up when you need to.", + "load_error": { + "title": "Couldn't load this part", + "desc": "The server didn't answer the way we hoped. Give it another go in a moment." + }, + "paused": { + "title": "Notifications are paused", + "desc": "Nothing reaches members until you resume them in Settings, under Danger zone." + }, + "activity": { + "title": "Activity", + "range_label": "Time range", + "ranges": { + "24h": "Last 24 hours", + "7d": "Last 7 days", + "30d": "Last 30 days" + }, + "ranges_short": { + "24h": "24h", + "7d": "7d", + "30d": "30d" + }, + "sent": "Emails delivered", + "failure_rate": "Failure rate", + "events": "Events published", + "skipped": "Emails skipped", + "vs_previous": "vs previous period", + "no_previous": "Nothing to compare yet", + "trend": "{label} over the selected range", + "tooltip": { + "sent": "{count, plural, =0 {No emails delivered} one {# email delivered} other {# emails delivered}}", + "events": "{count, plural, =0 {No events published} one {# event published} other {# events published}}", + "skipped": "{count, plural, =0 {No emails skipped} one {# email skipped} other {# emails skipped}}", + "failure": "{rate} failed - {failed} of {total, plural, one {# email} other {# emails}}", + "failure_none": "No emails sent" + } + }, + "types": { + "title": "Notification types", + "desc": "What members get by default, and whether they can change it. Changes save on their own and only apply to members who never touched their own preferences.", + "empty": { + "title": "No notification types yet", + "desc": "Core and plugins register their types here. Nothing has signed up so far." + }, + "read_only": "Changing type policies needs the \"Can edit\" notifications permission.", + "badges": { + "mandatory": "Always on", + "locked": "Locked for members", + "nowhere": "Not sent anywhere", + "no_template": "No email template", + "grouped": "Grouped" + }, + "summary": { + "label": "{type} defaults", + "list": "Notification list", + "push": "Push", + "email": "Email", + "on": "On", + "off": "Off", + "available": "Available", + "disabled": "Disabled", + "unsupported": "Not supported" + }, + "member_can_edit": { + "label": "Member can edit", + "desc": "Off locks this type to the defaults below. Members still get it, they just can't change it.", + "mandatory": "Mandatory types are always on and can't be changed by members." + }, + "list": { + "legend": "Notification list", + "default_on": { + "label": "Enabled by default", + "hint": "Members get it unless they turn it off." + }, + "default_off": { + "label": "Not enabled by default", + "hint": "Members get it only after turning it on." + }, + "disabled": { + "label": "Disabled", + "hint": "Nobody gets it and the option is hidden." + }, + "mandatory": "Mandatory types always show in the notification list." + }, + "push": { + "legend": "Push", + "available": { + "label": "Available", + "hint": "Members who allowed push can get it." + }, + "disabled": { + "label": "Disabled", + "hint": "Never sent as a push notification." + }, + "note": "Push delivery isn't built yet. This choice is saved for when it is." + }, + "email": { + "legend": "Email", + "default_on": { + "label": "Enabled by default", + "hint": "Members get an email unless they turn it off." + }, + "default_off": { + "label": "Not enabled by default", + "hint": "Members get an email only after turning it on." + }, + "disabled": { + "label": "Disabled", + "hint": "Never sent by email and the option is hidden." + }, + "unsupported": "This type has no email version, so it can't be emailed." + }, + "success": "Saved", + "success_desc": "\"{type}\" uses the new defaults for members who never chose for themselves." + }, + "reset_members": { + "button": "Reset all members to defaults", + "title": "Reset everyone's notification preferences?", + "desc": "{count, plural, =0 {No member has changed their notification preferences yet.} one {# member picked their own notification settings.} other {# members picked their own notification settings.}} Their list, push and email choices are replaced with the defaults on this page.", + "warning": "This can't be undone.", + "submit": "Reset all members", + "success": "Preferences reset", + "success_desc": "{count, plural, =0 {Everyone already used the defaults.} one {# member now uses the defaults.} other {# members now use the defaults.}}" + }, + "settings": { + "button": "Settings", + "title": "Notification settings", + "desc": "Tools and actions that reach every member.", + "groups": { + "tools": "Tools", + "danger": "Danger zone" + }, + "config_note": "Notifications are kept for {days, plural, one {# day} other {# days}}. Retention and worker batch sizes are set in the API config (vitnode.api.config.ts), under notifications." + }, + "tools": { + "run": "Run", + "run_label": "Run: {label}", + "test_email": { + "label": "Send a test email to me", + "desc": "Goes to your own address, and only yours.", + "success": "Test email queued", + "success_desc": "Delivery #{id} goes to your own address.", + "not_configured": "Notification email isn't set up yet, so there's nothing to test." + } + }, + "danger": { + "intro": "These affect every member at once. Each one asks before it runs.", + "paused_badge": "Paused", + "pause": { + "title": "Pause all notifications", + "desc": "Stop sending everything for a while, for example during a migration or a spam wave.", + "confirm_desc": "Members get nothing - no notification list, no email - until you resume. New events wait in the queue, nothing is lost.", + "submit": "Pause notifications", + "success": "Notifications paused", + "success_desc": "New events wait in the queue until you resume." + }, + "resume": { + "title": "Resume all notifications", + "desc": "Notifications are paused. Resume to send everything that waited.", + "confirm_desc": "Everything queued while paused goes out over the next few worker runs.", + "submit": "Resume notifications", + "success": "Notifications resumed", + "success_desc": "{count, plural, =0 {Nothing was waiting.} one {# waiting event is on its way.} other {# waiting events are on their way.}}" + }, + "cancel_emails": { + "title": "Cancel all queued emails", + "desc": "Drop emails that are waiting to be sent, including the next digests.", + "confirm_desc": "{count, plural, =0 {No emails are waiting right now.} one {# email that hasn't gone out yet is dropped, along with pending digests.} other {# emails that haven't gone out yet are dropped, along with pending digests.}} In-app notifications stay.", + "submit": "Cancel queued emails", + "success": "Email queue cleared", + "success_desc": "{count, plural, =0 {Nothing was waiting.} one {# queued email was dropped.} other {# queued emails were dropped.}}" + }, + "read_all": { + "title": "Mark everything as read for everyone", + "desc": "Clear the unread badge for every member at once.", + "confirm_desc": "{count, plural, =0 {Nothing is unread right now.} one {# unread notification becomes read} other {# unread notifications become read}} and every unread badge drops to zero.", + "submit": "Mark all as read", + "success": "Everything marked as read", + "success_desc": "{count, plural, =0 {Nothing was unread.} one {# notification is now read.} other {# notifications are now read.}}" + }, + "delete_all": { + "title": "Delete all notifications", + "desc": "Empty every member's notification list. Types, preferences and settings are kept.", + "confirm_desc": "{count, plural, =0 {There are no notifications to delete.} one {# notification is deleted for every member.} other {# notifications are deleted for every member.}} Emails already sent stay in people's inboxes. This can't be undone.", + "phrase": "delete all notifications", + "phrase_label": "Type to confirm", + "phrase_error": "Type the phrase exactly as shown.", + "submit": "Delete all notifications", + "success": "All notifications deleted", + "success_desc": "{count, plural, =0 {There was nothing to delete.} one {# notification was deleted.} other {# notifications were deleted.}}" + } + } + }, "integrations": { "title": "Integrations", "desc": "Status of the core services that power this instance.", @@ -1966,7 +2457,7 @@ "status": { "active": "Active", "inactive": "Inactive", - "warning": "Unreachable" + "warning": "Needs attention" }, "ai": { "title": "AI", @@ -2055,8 +2546,10 @@ "desc": "Scheduled background tasks such as clearing expired sessions and tokens.", "insecure": "Using the default CRON_SECRET - set a secure one in production.", "not_configured": "No cron adapter configured - jobs won't run automatically.", - "stale": "No job has run in over 6 hours - cron may be misconfigured or stopped. Check your cron setup.", - "jobs": "{count, plural, =0 {No jobs scheduled} one {# job scheduled} other {# jobs scheduled}}" + "stale": "Stopped - no job has run since . Check your cron setup.", + "jobs": "{count, plural, =0 {No jobs scheduled} one {# job scheduled} other {# jobs scheduled}}", + "stale_never": "Stopped - no job has run yet. Check your cron setup.", + "overdue": "{count, plural, one {# job is} other {# jobs are}} overdue - check the server logs." }, "content_preview": { "title": "Content Preview", diff --git a/packages/vitnode/src/pages/admin/system/notifications.tsx b/packages/vitnode/src/pages/admin/system/notifications.tsx new file mode 100644 index 000000000..de424a725 --- /dev/null +++ b/packages/vitnode/src/pages/admin/system/notifications.tsx @@ -0,0 +1,25 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminNotificationsRouteData } from "@/tanstack/admin/notifications/route"; + +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { loadAdminNotificationsRoute } from "@/tanstack/admin/notifications/route"; +import { AdminNotificationsRouteContent } from "@/tanstack/admin/notifications/screen"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminNotificationsPage = ({ + loaderData, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute({ + load: async ({ context, t }) => + await loadAdminNotificationsRoute({ ...context, t }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ + segments: ["core", "system", "notifications"], + }), +}); + +export default AdminNotificationsPage; diff --git a/packages/vitnode/src/pages/notifications.tsx b/packages/vitnode/src/pages/notifications.tsx new file mode 100644 index 000000000..6bb97e654 --- /dev/null +++ b/packages/vitnode/src/pages/notifications.tsx @@ -0,0 +1,34 @@ +import type { PluginRoutePageProps } from "@/routing"; + +import type { NotificationsRouteData } from "../tanstack/notifications/route"; +import type { NotificationsRouteSearch } from "../tanstack/notifications/route-search"; + +import { NotificationsBreadcrumb } from "../tanstack/notifications/breadcrumb"; +import { loadNotificationsRoute } from "../tanstack/notifications/route"; +import { NotificationsRouteContent } from "../tanstack/notifications/screen"; +import { defineAuthenticatedRoute } from "../tanstack/plugin-routes"; + +const NotificationsPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAuthenticatedRoute< + NotificationsRouteData, + NotificationsRouteSearch +>({ + load: async ({ context, search, t }) => + await loadNotificationsRoute({ ...context, search, t }), + head: ({ loaderData }) => ({ robots: "noindex, nofollow", ...loaderData }), + + breadcrumb: NotificationsBreadcrumb, +}); + +export default NotificationsPage; diff --git a/packages/vitnode/src/pages/settings/notifications.tsx b/packages/vitnode/src/pages/settings/notifications.tsx new file mode 100644 index 000000000..bf26270f1 --- /dev/null +++ b/packages/vitnode/src/pages/settings/notifications.tsx @@ -0,0 +1,36 @@ +import type { PluginRoutePageProps } from "@/routing"; + +import { NotificationSettingsPanel } from "@/tanstack/notifications/settings-screen"; +import { defineAuthenticatedRoute } from "@/tanstack/plugin-routes"; +import { settingsBreadcrumb } from "@/tanstack/settings/breadcrumb"; +import { notificationPreferencesQueryOptions } from "@/views/notifications/notifications-query"; + +interface NotificationSettingsData { + userId: number; +} + +const NotificationSettingsPage = ({ + loaderData, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAuthenticatedRoute({ + load: async ({ context }) => { + const userId = context.auth.user.id; + + await context.queryClient.query({ + ...notificationPreferencesQueryOptions({ userId }), + staleTime: "static", + }); + + return { userId }; + }, + head: ({ t }) => ({ + title: `${t("core.auth.settings.nav.notifications")} - ${t("core.auth.settings.title")}`, + }), + + breadcrumb: settingsBreadcrumb("notifications"), +}); + +export default NotificationSettingsPage; diff --git a/packages/vitnode/src/routes.test.ts b/packages/vitnode/src/routes.test.ts index 16e7b14f4..6f62f1796 100644 --- a/packages/vitnode/src/routes.test.ts +++ b/packages/vitnode/src/routes.test.ts @@ -95,6 +95,7 @@ describe("core's route tree", () => { expect(guarded).toEqual({ "/admin": "admin-guest", "/files": "authenticated", + "/notifications": "authenticated", "/login": "guest", "/register": "guest", "/settings": "authenticated", @@ -136,6 +137,7 @@ describe("core's route tree", () => { "/admin/core/users/roles", "/files", "/login", + "/notifications", "/search", ]) { expect(declared).toContain(`@vitnode/core:page#${path}`); diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index 17b6d0d86..c21b8d03a 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -8,6 +8,7 @@ import { ADMIN_FILES_NAMESPACES } from "./tanstack/admin/files/route"; import { normalizeAdminFilesRouteSearch } from "./tanstack/admin/files/route-search"; import { ADMIN_INTEGRATIONS_NAMESPACES } from "./tanstack/admin/integrations/route"; import { ADMIN_NAVIGATION_NAMESPACES } from "./tanstack/admin/navigation/route"; +import { ADMIN_NOTIFICATIONS_NAMESPACES } from "./tanstack/admin/notifications/route"; import { ADMIN_QUEUE_NAMESPACES } from "./tanstack/admin/queue/route"; import { normalizeQueueRouteSearch } from "./tanstack/admin/queue/route-search"; import { ADMIN_ROLES_NAMESPACES } from "./tanstack/admin/roles/route"; @@ -36,6 +37,8 @@ import { import { SSO_CALLBACK_NAMESPACES } from "./tanstack/auth/sso-route"; import { MY_FILES_NAMESPACES } from "./tanstack/files/route"; import { normalizeMyFilesRouteSearch } from "./tanstack/files/route-search"; +import { NOTIFICATIONS_NAMESPACES } from "./tanstack/notifications/namespaces"; +import { normalizeNotificationsRouteSearch } from "./tanstack/notifications/route-search"; import { AuthPendingSkeleton, CardsPendingSkeleton, @@ -140,6 +143,11 @@ export const routes = defineRoutes([ pendingComponent: FormPendingSkeleton, }), + page("notifications", { + component: lazy(() => import("./pages/settings/notifications")), + pendingComponent: FormPendingSkeleton, + }), + page("devices", { component: lazy(() => import("./pages/settings/devices")), pendingComponent: () => , @@ -153,6 +161,14 @@ export const routes = defineRoutes([ ], }), + page("/notifications", { + component: lazy(() => import("./pages/notifications")), + messages: NOTIFICATIONS_NAMESPACES, + pendingComponent: () => , + requires: "authenticated", + search: normalizeNotificationsRouteSearch, + }), + page("/files", { component: lazy(() => import("./pages/files")), messages: MY_FILES_NAMESPACES, @@ -225,6 +241,13 @@ export const routes = defineRoutes([ pendingComponent: CardsPendingSkeleton, }), + page("/admin/core/system/notifications", { + area: "admin", + component: lazy(() => import("./pages/admin/system/notifications")), + messages: ADMIN_NOTIFICATIONS_NAMESPACES, + pendingComponent: CardsPendingSkeleton, + }), + page("/admin/core/users", { area: "admin", component: lazy(() => import("./pages/admin/users/index")), diff --git a/packages/vitnode/src/tanstack/admin/cron/route.tsx b/packages/vitnode/src/tanstack/admin/cron/route.tsx index 4068e9b9e..4680fc4b4 100644 --- a/packages/vitnode/src/tanstack/admin/cron/route.tsx +++ b/packages/vitnode/src/tanstack/admin/cron/route.tsx @@ -1,6 +1,8 @@ import type { PluginRouteTranslator } from "@/routing"; import type { CronParams } from "@/views/admin/views/core/advanced/cron/cron-query"; +import { cronHealthQueryOptions } from "@/views/admin/views/core/advanced/cron/cron-query"; + import type { AdminScreenContext } from "../screen"; import { requireAdminPermission } from "../screen"; @@ -34,10 +36,16 @@ export const loadAdminCronRoute = async ({ }): Promise => { requireAdminPermission(adminAccess, CRON_VIEW_PERMISSION); - await queryClient.query({ - ...cronQuery({ params }), - staleTime: "static", - }); + await Promise.all([ + queryClient.query({ + ...cronQuery({ params }), + staleTime: "static", + }), + queryClient.query({ + ...cronHealthQueryOptions(), + staleTime: "static", + }), + ]); return { description: t("admin.advanced.cron.desc"), diff --git a/packages/vitnode/src/tanstack/admin/cron/screen.tsx b/packages/vitnode/src/tanstack/admin/cron/screen.tsx index d1fcfb58a..905dc1c8f 100644 --- a/packages/vitnode/src/tanstack/admin/cron/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/cron/screen.tsx @@ -5,6 +5,8 @@ import type { DataTableNavigation } from "@/components/table/navigation"; import { DataTableNavigationProvider } from "@/components/table/navigation"; import { PageTitle } from "@/components/ui/page-title"; +import { cronHealthQueryOptions } from "@/views/admin/views/core/advanced/cron/cron-query"; +import { CronSchedulerStatus } from "@/views/admin/views/core/advanced/cron/cron-scheduler-status"; import { CronTableContent } from "@/views/admin/views/core/advanced/cron/cron-table-content"; import type { AdminTableNavigate } from "../table-search"; @@ -29,6 +31,7 @@ export const AdminCronRouteContent = ({ title, }: AdminCronRouteProps) => { const { data } = useSuspenseQuery(cronQuery({ params })); + const { data: health } = useSuspenseQuery(cronHealthQueryOptions()); const onRun = useCronRunCallback(); const navigation = React.useMemo( @@ -49,9 +52,13 @@ export const AdminCronRouteContent = ({
- - - +
+ + + + + +
); diff --git a/packages/vitnode/src/tanstack/admin/notifications/index.ts b/packages/vitnode/src/tanstack/admin/notifications/index.ts new file mode 100644 index 000000000..3ce552021 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/notifications/index.ts @@ -0,0 +1,25 @@ +export { + invalidateNotificationsAdmin, + useNotificationsAdminActions, +} from "./query"; +export type { AdminNotificationsRouteData } from "./route"; +export { + ADMIN_NOTIFICATIONS_NAMESPACES, + loadAdminNotificationsRoute, +} from "./route"; +export { AdminNotificationsRouteContent } from "./screen"; + +export type { + NotificationsAdminActions, + NotificationTypePolicyPatch, +} from "@/views/admin/views/core/system/notifications/notifications-mutations"; +export type { + AdminNotificationsOverview, + AdminNotificationStats, + AdminNotificationType, +} from "@/views/admin/views/core/system/notifications/notifications-query"; +export { + notificationsAdminQueryRoot, + notificationsOverviewQueryOptions, + notificationStatsQueryOptions, +} from "@/views/admin/views/core/system/notifications/notifications-query"; diff --git a/packages/vitnode/src/tanstack/admin/notifications/query.ts b/packages/vitnode/src/tanstack/admin/notifications/query.ts new file mode 100644 index 000000000..012bc0d0e --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/notifications/query.ts @@ -0,0 +1,48 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { useQueryClient } from "@tanstack/react-query"; +import React from "react"; + +import type { NotificationsAdminActions } from "@/views/admin/views/core/system/notifications/notifications-mutations"; + +import { notificationsAdminActionsInBrowser } from "@/views/admin/views/core/system/notifications/notifications-mutations"; +import { notificationsAdminQueryRoot } from "@/views/admin/views/core/system/notifications/notifications-query"; + +export const invalidateNotificationsAdmin = async ( + queryClient: QueryClient, +): Promise => + await queryClient.invalidateQueries({ + queryKey: notificationsAdminQueryRoot, + }); + +export const useNotificationsAdminActions = (): NotificationsAdminActions => { + const queryClient = useQueryClient(); + + return React.useMemo(() => { + const refreshOnSuccess = + ( + action: (...args: Args) => Promise, + ) => + async (...args: Args): Promise => { + const result = await action(...args); + if (result.error === undefined) { + await invalidateNotificationsAdmin(queryClient); + } + + return result; + }; + + const browser = notificationsAdminActionsInBrowser; + + return { + cancelQueuedEmails: refreshOnSuccess(browser.cancelQueuedEmails), + deleteAll: refreshOnSuccess(browser.deleteAll), + markEverythingRead: refreshOnSuccess(browser.markEverythingRead), + pause: refreshOnSuccess(browser.pause), + resetMemberPreferences: refreshOnSuccess(browser.resetMemberPreferences), + resume: refreshOnSuccess(browser.resume), + sendTestEmail: refreshOnSuccess(browser.sendTestEmail), + updateTypePolicy: refreshOnSuccess(browser.updateTypePolicy), + }; + }, [queryClient]); +}; diff --git a/packages/vitnode/src/tanstack/admin/notifications/route.tsx b/packages/vitnode/src/tanstack/admin/notifications/route.tsx new file mode 100644 index 000000000..b7341c525 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/notifications/route.tsx @@ -0,0 +1,57 @@ +import type { PluginRouteTranslator } from "@/routing"; + +import { + notificationsOverviewQueryOptions, + notificationStatsQueryOptions, +} from "@/views/admin/views/core/system/notifications/notifications-query"; + +import type { AdminScreenContext } from "../screen"; + +import { getIntlRuntime } from "../../i18n/runtime"; +import { requireAdminPermission } from "../screen"; + +export const ADMIN_NOTIFICATIONS_NAMESPACES = [ + "admin.system.notifications", + "core.global", +] as const; + +export interface AdminNotificationsRouteData { + description: string; + title: string; +} + +export const NOTIFICATIONS_MODULE = "notifications"; + +const NOTIFICATIONS_VIEW_PERMISSION = { + module: NOTIFICATIONS_MODULE, + permission: "can_view", +} as const; + +export const loadAdminNotificationsRoute = async ({ + adminAccess, + queryClient, + t, +}: AdminScreenContext & { + t: PluginRouteTranslator; +}): Promise => { + requireAdminPermission(adminAccess, NOTIFICATIONS_VIEW_PERMISSION); + + await Promise.all([ + queryClient.query({ + ...notificationsOverviewQueryOptions(), + staleTime: "static", + }), + queryClient.query({ + ...notificationStatsQueryOptions({ + range: "7d", + timeZone: getIntlRuntime().timeZone ?? "UTC", + }), + staleTime: "static", + }), + ]); + + return { + description: t("admin.system.notifications.desc"), + title: t("admin.system.notifications.title"), + }; +}; diff --git a/packages/vitnode/src/tanstack/admin/notifications/screen.tsx b/packages/vitnode/src/tanstack/admin/notifications/screen.tsx new file mode 100644 index 000000000..9bd5c924b --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/notifications/screen.tsx @@ -0,0 +1,56 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; + +import { PageTitle } from "@/components/ui/page-title"; +import { CONFIG_PLUGIN } from "@/config"; +import { NotificationsContent } from "@/views/admin/views/core/system/notifications/notifications-content"; +import { notificationsOverviewQueryOptions } from "@/views/admin/views/core/system/notifications/notifications-query"; +import { NotificationsSettingsSheet } from "@/views/admin/views/core/system/notifications/settings-sheet"; + +import type { AdminNotificationsRouteData } from "./route"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { useAdminPermission } from "../permissions"; +import { useNotificationsAdminActions } from "./query"; +import { ADMIN_NOTIFICATIONS_NAMESPACES, NOTIFICATIONS_MODULE } from "./route"; + +export const AdminNotificationsRouteContent = ({ + description, + title, +}: AdminNotificationsRouteData) => { + const { data } = useSuspenseQuery(notificationsOverviewQueryOptions()); + const actions = useNotificationsAdminActions(); + const canEdit = useAdminPermission({ + module: NOTIFICATIONS_MODULE, + permission: "can_edit", + plugin: CONFIG_PLUGIN.pluginId, + }); + const canManage = useAdminPermission({ + module: NOTIFICATIONS_MODULE, + permission: "can_manage", + plugin: CONFIG_PLUGIN.pluginId, + }); + + return ( + +
+ + {canManage ? ( + + ) : null} + + + +
+
+ ); +}; diff --git a/packages/vitnode/src/tanstack/admin/screen-queries.test.ts b/packages/vitnode/src/tanstack/admin/screen-queries.test.ts index 8a3f44426..a11f24da2 100644 --- a/packages/vitnode/src/tanstack/admin/screen-queries.test.ts +++ b/packages/vitnode/src/tanstack/admin/screen-queries.test.ts @@ -21,6 +21,7 @@ import { adminFilesQueryRoot, } from "@/views/admin/views/core/system/files/files-query"; import { integrationsQueryKey } from "@/views/admin/views/core/system/integrations/integrations-query"; +import { notificationsAdminQueryRoot } from "@/views/admin/views/core/system/notifications/notifications-query"; import { ADMIN_SESSION_QUERY_KEY } from "./state"; @@ -39,6 +40,7 @@ const ROOTS = { "debug-queue": debugQueueQueryKey, files: adminFilesQueryRoot, integrations: integrationsQueryKey, + notifications: notificationsAdminQueryRoot, queue: queueQueryRoot, "search-index": searchIndexQueryKey, }; diff --git a/packages/vitnode/src/tanstack/admin/users/detail-route.tsx b/packages/vitnode/src/tanstack/admin/users/detail-route.tsx index 9706cc55e..9d8e5847b 100644 --- a/packages/vitnode/src/tanstack/admin/users/detail-route.tsx +++ b/packages/vitnode/src/tanstack/admin/users/detail-route.tsx @@ -4,6 +4,10 @@ import type { PluginRouteTranslator } from "@/routing"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import { ADMIN_USER_PERMISSIONS } from "@/views/admin/views/core/shared/admin-permissions"; +import { + adminUserDevicesQueryOptions, + adminUserSsoQueryOptions, +} from "@/views/admin/views/core/users/detail/user-account-query"; import { normalizeAdminUserId } from "@/views/admin/views/core/users/detail/user-query"; import type { AdminScreenContext } from "../screen"; @@ -14,6 +18,8 @@ import { adminUserQuery } from "./query"; export const ADMIN_USER_NAMESPACES = [ "admin.user", + "core.auth.settings.notifications", + "core.auth.settings.sso", "core.global", "core.search", ] as const; @@ -48,10 +54,25 @@ export const loadAdminUserRoute = async ({ const adminUserId = adminIdentityOf(adminAccess); - const user = await queryClient.query({ - ...adminUserQuery({ adminUserId, id }), - staleTime: "static", - }); + const userId = Number(id); + const [user] = await Promise.all([ + queryClient.query({ + ...adminUserQuery({ adminUserId, id }), + staleTime: "static", + }), + queryClient + .query({ + ...adminUserDevicesQueryOptions({ adminUserId, userId }), + staleTime: "static", + }) + .catch(() => null), + queryClient + .query({ + ...adminUserSsoQueryOptions({ adminUserId, userId }), + staleTime: "static", + }) + .catch(() => null), + ]); return { adminUserId, diff --git a/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx b/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx index 5d7b85eb0..dfe2ae402 100644 --- a/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx +++ b/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx @@ -5,8 +5,12 @@ import { useTranslations } from "use-intl"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import { useAdminStaffPermissions } from "@/components/staff-permission/provider"; +import { UserConnectedAccountsCard } from "@/views/admin/views/core/users/detail/user-connected-accounts"; import { UserDetailContent } from "@/views/admin/views/core/users/detail/user-detail-content"; +import { UserDevicesCard } from "@/views/admin/views/core/users/detail/user-devices"; +import { UserNotificationsPanel } from "@/views/admin/views/core/users/detail/user-notifications"; import { canEditAdminUser } from "@/views/admin/views/core/users/detail/user-query"; +import { UserSecurityPanel } from "@/views/admin/views/core/users/detail/user-security"; import { adminUserTimelineQueryOptions } from "@/views/admin/views/core/users/detail/user-timeline-query"; import { searchAdminRolesInBrowser } from "@/views/admin/views/core/users/roles/roles-query"; import { SearchFeedList } from "@/views/search/search-feed-content"; @@ -38,14 +42,41 @@ const UserTimeline = ({ const AdminUserScreen = ({ adminUserId, id, locale }: AdminUserRouteProps) => { const t = useTranslations("admin.user.show.images"); const { data: user } = useSuspenseQuery(adminUserQuery({ adminUserId, id })); - const { onRemoveImage, onUpdate, onUpdateRoles, onUploadImage } = - useAdminUserMutations(); + const { + onRemoveImage, + onUpdate, + onUpdateRoles, + onUploadImage, + onVerifyEmail, + } = useAdminUserMutations(); const permissions = useAdminStaffPermissions(); + const canEdit = canEditAdminUser(permissions, user); return ( -
+
+ } + devices={ + + } + notifications={ + + } onRemoveImage={async (userId, kind) => { await onRemoveImage(userId, kind); toast.success(t(`${kind}.removed`), { @@ -60,7 +91,15 @@ const AdminUserScreen = ({ adminUserId, id, locale }: AdminUserRouteProps) => { description: t("uploadedDesc"), }); }} + onVerifyEmail={onVerifyEmail} searchRoles={searchAdminRolesInBrowser} + security={ + + } timeline={ { queryClient.removeQueries({ queryKey: MY_FILES_IDENTITY_ROOT }); queryClient.removeQueries({ queryKey: DEVICES_IDENTITY_ROOT }); queryClient.removeQueries({ queryKey: PASSKEYS_IDENTITY_ROOT }); queryClient.removeQueries({ queryKey: SSO_CONNECTIONS_IDENTITY_ROOT }); + queryClient.removeQueries({ queryKey: NOTIFICATIONS_IDENTITY_ROOT }); }; diff --git a/packages/vitnode/src/tanstack/auth/state.test.ts b/packages/vitnode/src/tanstack/auth/state.test.ts index 2080f0075..35873572d 100644 --- a/packages/vitnode/src/tanstack/auth/state.test.ts +++ b/packages/vitnode/src/tanstack/auth/state.test.ts @@ -32,8 +32,10 @@ const userFixture = (overrides: Partial = {}): AuthUser => ({ name: "Test", nameCode: "test", newsletter: false, + notifications: { revision: 0, unread: 0 }, roleId: 1, showRealName: false, + timeZone: null, ...overrides, }); diff --git a/packages/vitnode/src/tanstack/layout/header.tsx b/packages/vitnode/src/tanstack/layout/header.tsx index c61d41e11..b2df1a044 100644 --- a/packages/vitnode/src/tanstack/layout/header.tsx +++ b/packages/vitnode/src/tanstack/layout/header.tsx @@ -27,6 +27,7 @@ import { import { prefetchSession } from "../auth/session-query"; import { useLocale } from "../i18n/locale"; import { GLOBAL_NAMESPACE, intlQueryOptions } from "../i18n/query"; +import { NotificationsBell } from "../notifications/bell"; import { MobileUserMenu } from "./mobile-user-menu"; const asNavigationNodes = ( @@ -93,6 +94,7 @@ export const Header = ({ user }: { user?: React.ReactNode }) => { } moreNavigationLabel={t("more_navigation")} navigation={navigation} + notifications={} user={user} /> diff --git a/packages/vitnode/src/tanstack/notifications/actions.ts b/packages/vitnode/src/tanstack/notifications/actions.ts new file mode 100644 index 000000000..404f63488 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/actions.ts @@ -0,0 +1,110 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "@tanstack/react-router"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { NotificationState } from "@/lib/notifications/types"; +import type { NotificationItemView } from "@/views/notifications/notifications-query"; + +import { notificationStateStore } from "@/views/notifications/notification-state-store"; +import { + archiveNotificationInBrowser, + markAllNotificationsReadInBrowser, + markNotificationReadInBrowser, + markNotificationUnreadInBrowser, +} from "@/views/notifications/notifications-actions"; +import { fetchNotificationState } from "@/views/notifications/notifications-query"; + +import { + invalidateNotificationLists, + markAllCachedRead, + patchCachedNotification, +} from "./cache"; + +export const useNotificationActions = (userId: number) => { + const queryClient = useQueryClient(); + const router = useRouter(); + const t = useTranslations("core.global.notifications"); + const tErrors = useTranslations("core.global.errors"); + + return React.useMemo(() => { + const apply = (state: NotificationState) => + notificationStateStore.apply(userId, state); + + const recover = async () => { + toast.error(tErrors("title"), { description: t("action_failed") }); + const latest = await fetchNotificationState().catch(() => null); + if (latest) apply(latest); + await invalidateNotificationLists(queryClient, userId); + }; + + const run = async ( + action: () => Promise, + optimistic: () => void, + ): Promise => { + optimistic(); + try { + apply(await action()); + + return true; + } catch { + await recover(); + + return false; + } + }; + + return { + archive: async (item: NotificationItemView) => { + const ok = await run( + async () => await archiveNotificationInBrowser({ id: item.id }), + () => patchCachedNotification(queryClient, userId, item.id, null), + ); + if (ok) toast.success(t("archived"), { description: item.title }); + }, + markAllRead: async () => { + const ok = await run( + async () => await markAllNotificationsReadInBrowser(), + () => markAllCachedRead(queryClient, userId), + ); + if (ok) + toast.success(t("all_read"), { description: t("all_read_desc") }); + }, + open: (item: NotificationItemView, { newTab }: { newTab: boolean }) => { + if (item.unread) { + void run( + async () => + await markNotificationReadInBrowser({ + id: item.id, + throughSeq: item.activitySeq, + }), + () => + patchCachedNotification(queryClient, userId, item.id, { + unread: false, + }), + ); + } + + if (!newTab && item.available && item.target) { + void router.navigate({ href: item.target }); + } + }, + toggleRead: async (item: NotificationItemView) => { + await run( + async () => + item.unread + ? await markNotificationReadInBrowser({ + id: item.id, + throughSeq: item.activitySeq, + }) + : await markNotificationUnreadInBrowser({ id: item.id }), + () => + patchCachedNotification(queryClient, userId, item.id, { + unread: !item.unread, + }), + ); + }, + }; + }, [queryClient, router, t, tErrors, userId]); +}; diff --git a/packages/vitnode/src/tanstack/notifications/bell.test.tsx b/packages/vitnode/src/tanstack/notifications/bell.test.tsx new file mode 100644 index 000000000..a9f005914 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/bell.test.tsx @@ -0,0 +1,132 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { NotificationItemView } from "@/views/notifications/notifications-query"; + +import { notificationStateStore } from "@/views/notifications/notification-state-store"; +import { recentNotificationsQueryOptions } from "@/views/notifications/notifications-query"; + +import { SESSION_QUERY_KEY } from "../auth/state"; +import { NotificationsBell } from "./bell"; + +const USER_ID = 3; + +const item = ( + overrides: Partial, +): NotificationItemView => ({ + activitySeq: 2, + actorCount: 0, + actors: [], + available: true, + body: null, + category: "social", + createdAt: "2026-10-04T08:00:00.000Z", + eventCount: 2, + id: 11, + lastActivityAt: "2026-10-04T08:00:00.000Z", + pluginId: "@acme/test", + readAt: null, + subject: null, + target: "/threads/1", + title: "Two new replies", + type: "test.reply", + unread: true, + ...overrides, +}); + +const mount = async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(SESSION_QUERY_KEY, { + user: { id: USER_ID, notifications: { revision: 4, unread: 2 } }, + }); + queryClient.setQueryData( + recentNotificationsQueryOptions({ userId: USER_ID }).queryKey, + { + items: [ + item({}), + item({ id: 12, target: null, title: "Older", unread: false }), + ], + nextCursor: null, + }, + ); + + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routeTree: createRootRoute({ component: () => }), + }); + + render( + + + + + , + ); + + return await screen.findByRole("button", { + name: "core.global.notifications.bell_label_unread", + }); +}; + +describe("NotificationsBell", () => { + const fetchMock = vi.fn( + async () => + await Promise.resolve( + new Response(JSON.stringify({ revision: 5, unread: 1 }), { + headers: { "content-type": "application/json" }, + status: 200, + }), + ), + ); + + beforeEach(() => { + notificationStateStore.reset(); + fetchMock.mockClear(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows the session's unread count without loading the inbox", async () => { + await mount(); + + expect(screen.getByTestId("notifications-badge").textContent).toBe("2"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("never marks anything read just because the dropdown opened", async () => { + const trigger = await mount(); + fireEvent.click(trigger); + + expect(await screen.findByText("Two new replies")).toBeTruthy(); + expect(screen.getByText("Older")).toBeTruthy(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(screen.getByTestId("notifications-badge").textContent).toBe("2"); + }); + + it("marks an item read through the activity shown, when it is clicked", async () => { + const trigger = await mount(); + fireEvent.click(trigger); + fireEvent.click(await screen.findByText("Two new replies")); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const [url, init] = fetchMock.mock.calls[0] as [string | URL, RequestInit]; + expect(String(url)).toMatch(/\/notifications\/11\/read$/); + expect(init.method?.toUpperCase()).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ throughSeq: 2 }); + + await waitFor(() => + expect(screen.getByTestId("notifications-badge").textContent).toBe("1"), + ); + }); +}); diff --git a/packages/vitnode/src/tanstack/notifications/bell.tsx b/packages/vitnode/src/tanstack/notifications/bell.tsx new file mode 100644 index 000000000..027637be9 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/bell.tsx @@ -0,0 +1,60 @@ +import { useQuery } from "@tanstack/react-query"; +import React from "react"; + +import type { NotificationState } from "@/lib/notifications/types"; + +import { useNotificationState } from "@/views/notifications/notification-state-store"; +import { NotificationsBellContent } from "@/views/notifications/notifications-bell-content"; +import { recentNotificationsQueryOptions } from "@/views/notifications/notifications-query"; + +import { useSessionQuery } from "../auth/session-query"; +import { useNotificationActions } from "./actions"; + +const SignedInBell = ({ + sessionState, + userId, +}: { + sessionState: NotificationState; + userId: number; +}) => { + const [open, setOpen] = React.useState(false); + const state = useNotificationState(userId); + const actions = useNotificationActions(userId); + const recent = useQuery({ + ...recentNotificationsQueryOptions({ userId }), + enabled: open, + }); + + return ( + void actions.markAllRead()} + onOpenChange={setOpen} + onOpenItem={(item, options) => { + actions.open(item, options); + if (!options.newTab) setOpen(false); + }} + onRetry={() => void recent.refetch()} + open={open} + recent={{ + isError: recent.isError, + isPending: recent.isPending, + items: recent.data?.items, + }} + unread={(state ?? sessionState).unread} + /> + ); +}; + +export const NotificationsBell = () => { + const { data: session } = useSessionQuery(); + const user = session?.user; + if (!user) return null; + + return ( + + ); +}; diff --git a/packages/vitnode/src/tanstack/notifications/breadcrumb.tsx b/packages/vitnode/src/tanstack/notifications/breadcrumb.tsx new file mode 100644 index 000000000..18cab2753 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/breadcrumb.tsx @@ -0,0 +1,7 @@ +import { useTranslations } from "use-intl"; + +export const NotificationsBreadcrumb = () => { + const t = useTranslations("core.notifications"); + + return <>{t("title")}; +}; diff --git a/packages/vitnode/src/tanstack/notifications/cache.ts b/packages/vitnode/src/tanstack/notifications/cache.ts new file mode 100644 index 000000000..cf26ab170 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/cache.ts @@ -0,0 +1,62 @@ +import type { InfiniteData, QueryClient } from "@tanstack/react-query"; + +import type { + NotificationItemView, + NotificationsPage, +} from "@/views/notifications/notifications-query"; + +import { notificationsListRoot } from "@/views/notifications/notifications-query"; + +type ListData = InfiniteData | NotificationsPage; + +const mapPages = ( + data: ListData | undefined, + map: (items: NotificationItemView[]) => NotificationItemView[], +): ListData | undefined => { + if (!data) return data; + if ("pages" in data) { + return { + ...data, + pages: data.pages.map(page => ({ ...page, items: map(page.items) })), + }; + } + + return { ...data, items: map(data.items) }; +}; + +export const patchCachedNotification = ( + queryClient: QueryClient, + userId: number, + id: number, + patch: null | Partial, +): void => { + queryClient.setQueriesData( + { queryKey: notificationsListRoot(userId) }, + data => + mapPages(data, items => + patch === null + ? items.filter(item => item.id !== id) + : items.map(item => (item.id === id ? { ...item, ...patch } : item)), + ), + ); +}; + +export const markAllCachedRead = ( + queryClient: QueryClient, + userId: number, +): void => { + queryClient.setQueriesData( + { queryKey: notificationsListRoot(userId) }, + data => + mapPages(data, items => items.map(item => ({ ...item, unread: false }))), + ); +}; + +export const invalidateNotificationLists = async ( + queryClient: QueryClient, + userId: number, +): Promise => { + await queryClient.invalidateQueries({ + queryKey: notificationsListRoot(userId), + }); +}; diff --git a/packages/vitnode/src/tanstack/notifications/index.ts b/packages/vitnode/src/tanstack/notifications/index.ts new file mode 100644 index 000000000..1e7316dce --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/index.ts @@ -0,0 +1,6 @@ +export { useNotificationActions } from "./actions"; +export { NotificationsBell } from "./bell"; +export { NOTIFICATIONS_NAMESPACES } from "./namespaces"; +export { NotificationsRouteContent } from "./screen"; +export { NotificationSettingsPanel } from "./settings-screen"; +export { NotificationStateSync } from "./state-sync"; diff --git a/packages/vitnode/src/tanstack/notifications/namespaces.ts b/packages/vitnode/src/tanstack/notifications/namespaces.ts new file mode 100644 index 000000000..f7d1154f5 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/namespaces.ts @@ -0,0 +1,4 @@ +export const NOTIFICATIONS_NAMESPACES = [ + "core.global", + "core.notifications", +] as const; diff --git a/packages/vitnode/src/tanstack/notifications/route-search.ts b/packages/vitnode/src/tanstack/notifications/route-search.ts new file mode 100644 index 000000000..9e8e21ca1 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/route-search.ts @@ -0,0 +1,15 @@ +export interface NotificationsRouteSearch { + category?: string; + filter?: "unread"; +} + +const CATEGORY = /^[a-z0-9_-]{1,50}$/; + +export const normalizeNotificationsRouteSearch = ( + input: Record, +): NotificationsRouteSearch => ({ + ...(typeof input.category === "string" && CATEGORY.test(input.category) + ? { category: input.category } + : {}), + ...(input.filter === "unread" ? { filter: "unread" as const } : {}), +}); diff --git a/packages/vitnode/src/tanstack/notifications/route.ts b/packages/vitnode/src/tanstack/notifications/route.ts new file mode 100644 index 000000000..2906845b7 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/route.ts @@ -0,0 +1,53 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import type { PluginRouteTranslator } from "@/routing"; + +import { + notificationPreferencesQueryOptions, + notificationsInfiniteQueryOptions, +} from "@/views/notifications/notifications-query"; + +import type { NotificationsRouteSearch } from "./route-search"; + +export interface NotificationsRouteData { + description: string; + title: string; + userId: number; +} + +export const loadNotificationsRoute = async ({ + auth, + queryClient, + search, + t, +}: { + auth: { user: { id: number } }; + queryClient: QueryClient; + search: NotificationsRouteSearch; + t: PluginRouteTranslator; +}): Promise => { + const userId = auth.user.id; + + await Promise.all([ + queryClient.infiniteQuery({ + ...notificationsInfiniteQueryOptions({ + filter: { + category: search.category, + unread: search.filter === "unread", + }, + userId, + }), + staleTime: "static", + }), + queryClient.query({ + ...notificationPreferencesQueryOptions({ userId }), + staleTime: "static", + }), + ]); + + return { + description: t("core.notifications.desc"), + title: t("core.notifications.title"), + userId, + }; +}; diff --git a/packages/vitnode/src/tanstack/notifications/screen.tsx b/packages/vitnode/src/tanstack/notifications/screen.tsx new file mode 100644 index 000000000..9b1d03471 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/screen.tsx @@ -0,0 +1,263 @@ +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { BellIcon, CheckCheckIcon, Settings2Icon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { PageTitle } from "@/components/ui/page-title"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { NotificationItem } from "@/views/notifications/notification-item"; +import { useNotificationState } from "@/views/notifications/notification-state-store"; +import { NOTIFICATION_SETTINGS_HREF } from "@/views/notifications/notifications-bell-content"; +import { + notificationPreferencesQueryOptions, + notificationsInfiniteQueryOptions, +} from "@/views/notifications/notifications-query"; + +import type { NotificationsRouteData } from "./route"; +import type { NotificationsRouteSearch } from "./route-search"; + +import { RouteMessages } from "../i18n/route-messages"; +import { useNotificationActions } from "./actions"; +import { NOTIFICATIONS_NAMESPACES } from "./namespaces"; + +const ALL = "all"; + +type NotificationsScreenProps = NotificationsRouteData & { + navigate: (options: { search: NotificationsRouteSearch }) => unknown; + search: NotificationsRouteSearch; +}; + +const NotificationsList = ({ + search, + userId, +}: { + search: NotificationsRouteSearch; + userId: number; +}) => { + const t = useTranslations("core.notifications"); + const actions = useNotificationActions(userId); + const query = useInfiniteQuery( + notificationsInfiniteQueryOptions({ + filter: { category: search.category, unread: search.filter === "unread" }, + userId, + }), + ); + + if (query.isPending) { + return ( +
+ {t("loading")} + {Array.from({ length: 4 }, (_, index) => ( +
+ +
+ + +
+
+ ))} +
+ ); + } + + if (query.isError) { + return ( +
+

+ {t("load_error")} +

+ +
+ ); + } + + const items = query.data.pages.flatMap(page => page.items); + if (items.length === 0) { + return ( + + + + + + + {search.filter === "unread" + ? t("empty_unread_title") + : t("empty_title")} + + {t("empty_desc")} + + + ); + } + + return ( +
+
    + {items.map(item => ( + void actions.archive(item)} + onOpen={actions.open} + onToggleRead={item => void actions.toggleRead(item)} + /> + ))} +
+ {query.hasNextPage ? ( + + ) : null} +
+ ); +}; + +export const NotificationsRouteContent = ({ + description, + navigate, + search, + title, + userId, +}: NotificationsScreenProps) => ( + + + +); + +const NotificationsScreen = ({ + description, + navigate, + search, + title, + userId, +}: NotificationsScreenProps) => { + const t = useTranslations("core.notifications"); + const actions = useNotificationActions(userId); + const state = useNotificationState(userId); + const preferences = useQuery(notificationPreferencesQueryOptions({ userId })); + const categories = [ + ...new Map( + (preferences.data?.types ?? []).map(type => [ + type.category, + type.categoryLabel, + ]), + ), + ]; + + return ( +
+
+ +
+ + +
+
+ +
+ + navigate({ + search: { + ...search, + filter: value === "unread" ? "unread" : undefined, + }, + }) + } + value={search.filter ?? ALL} + > + + {t("filter_all")} + + {t("filter_unread")} + {state && state.unread > 0 ? ` (${state.unread})` : ""} + + + + + {categories.length > 1 ? ( + + ) : null} +
+ + +
+ ); +}; diff --git a/packages/vitnode/src/tanstack/notifications/settings-screen.tsx b/packages/vitnode/src/tanstack/notifications/settings-screen.tsx new file mode 100644 index 000000000..eea7219aa --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/settings-screen.tsx @@ -0,0 +1,182 @@ +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { NotificationEmailMode } from "@/lib/notifications/types"; +import type { + NotificationPreferencesView, + NotificationPreferenceTypeView, +} from "@/views/notifications/notifications-query"; + +import { PageTitle } from "@/components/ui/page-title"; +import { updateNotificationPreferencesInBrowser } from "@/views/notifications/notifications-actions"; +import { + notificationPreferencesQueryKey, + notificationPreferencesQueryOptions, +} from "@/views/notifications/notifications-query"; +import { NotificationChannelsGroup } from "@/views/notifications/settings/channels-card"; +import { + emailChannelState, + emailOffPatch, + emailOnPatch, + emailSnapshot, +} from "@/views/notifications/settings/email-channel"; +import { NotificationTypeItem } from "@/views/notifications/settings/type-item"; + +import { sessionQueryOptions } from "../auth/session-query"; + +type UpdateBody = Parameters[0]; + +const usePreferenceSaver = (userId: number) => { + const queryClient = useQueryClient(); + const t = useTranslations("core.auth.settings.notifications"); + const tErrors = useTranslations("core.global.errors"); + + return React.useCallback( + async (body: UpdateBody, description?: string): Promise => { + const key = notificationPreferencesQueryKey(userId); + queryClient.setQueryData(key, data => + data + ? { + ...data, + types: data.types.map(type => + body.types?.[type.id] + ? { + ...type, + value: { ...type.value, ...body.types[type.id] }, + } + : type, + ), + } + : data, + ); + + let saved = true; + try { + await updateNotificationPreferencesInBrowser(body); + toast.success(t("saved"), { + description: description ?? t("saved_desc"), + }); + } catch { + saved = false; + toast.error(tErrors("title"), { + description: tErrors("internal_server_error"), + }); + } + await queryClient.invalidateQueries({ queryKey: key }); + + return saved; + }, + [queryClient, t, tErrors, userId], + ); +}; + +export const NotificationSettingsPanel = ({ userId }: { userId: number }) => { + const t = useTranslations("core.auth.settings.notifications"); + const tSettings = useTranslations("core.auth.settings"); + const { data } = useSuspenseQuery( + notificationPreferencesQueryOptions({ userId }), + ); + const { data: session } = useSuspenseQuery(sessionQueryOptions()); + const save = usePreferenceSaver(userId); + const [openId, setOpenId] = React.useState(null); + const emailBeforeStopRef = React.useRef>(null); + + const groups = [ + ...data.types + .reduce((map, type) => { + const group = map.get(type.category) ?? { + label: type.categoryLabel, + types: [], + }; + group.types.push(type); + + return map.set(type.category, group); + }, new Map()) + .values(), + ]; + + const setEmail = (on: boolean) => { + if (on) { + const emailBeforeStop = emailBeforeStopRef.current; + void save( + { types: emailOnPatch(data.types, emailBeforeStop) }, + emailBeforeStop + ? t("channels.email.restored_toast_desc") + : t("channels.email.on_toast_desc"), + ); + emailBeforeStopRef.current = null; + + return; + } + + emailBeforeStopRef.current = emailSnapshot(data.types); + void save( + { types: emailOffPatch(data.types) }, + t("channels.email.stopped_toast_desc"), + ); + }; + + return ( +
+ + + + +
+

+ {t("types.title")} +

+ {groups.length === 0 ? ( +

{t("types.empty")}

+ ) : ( +
    + {groups.map(group => ( + + {groups.length > 1 ? ( +
  • +

    {group.label}

    +
  • + ) : null} + {group.types.map(type => ( + { + void save({ types: { [type.id]: patch } }); + }} + onToggle={() => { + setOpenId(current => + current === type.id ? null : type.id, + ); + }} + type={type} + /> + ))} +
    + ))} +
+ )} +
+
+ ); +}; diff --git a/packages/vitnode/src/tanstack/notifications/state-sync.test.tsx b/packages/vitnode/src/tanstack/notifications/state-sync.test.tsx new file mode 100644 index 000000000..66855935f --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/state-sync.test.tsx @@ -0,0 +1,163 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + notificationStateStore, + useNotificationState, +} from "@/views/notifications/notification-state-store"; +import { notificationsStateChannel } from "@/ws/notifications"; +import { VitNodeWebSocketProvider } from "@/ws/provider"; + +import { SESSION_QUERY_KEY } from "../auth/state"; +import { NotificationStateSync } from "./state-sync"; + +const USER_ID = 7; +const SAME_ORIGIN_API_URL = "http://localhost:3000"; + +class FakeWebSocket { + constructor(url: string) { + this.url = url; + sockets.push(this); + } + + static readonly CLOSED = 3; + static readonly CLOSING = 2; + static readonly CONNECTING = 0; + static readonly OPEN = 1; + + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onopen: (() => void) | null = null; + readyState = FakeWebSocket.CONNECTING; + readonly url: string; + + close() { + this.readyState = FakeWebSocket.CLOSED; + } + + drop() { + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.(); + } + + open() { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + + push(data: { reason: string; revision: number; unread: number }) { + this.onmessage?.({ + data: JSON.stringify({ data, id: notificationsStateChannel.id }), + }); + } + + send() {} +} + +let sockets: FakeWebSocket[] = []; + +const Count = () => { + const state = useNotificationState(USER_ID); + + return ( + {state ? `${state.unread}@${state.revision}` : "none"} + ); +}; + +const mount = ( + fetchState: () => Promise<{ revision: number; unread: number }>, +) => { + const queryClient = new QueryClient(); + queryClient.setQueryData(SESSION_QUERY_KEY, { + user: { id: USER_ID, notifications: { revision: 5, unread: 1 } }, + }); + + render( + + + + + + , + ); + + return queryClient; +}; + +describe("NotificationStateSync", () => { + beforeEach(() => { + sockets = []; + notificationStateStore.reset(); + vi.stubEnv("VITNODE_API_URL", "http://localhost:8000"); + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("BroadcastChannel", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("applies only newer revisions, whatever order they arrive in", async () => { + const fetchState = vi.fn( + async () => await Promise.resolve({ revision: 6, unread: 2 }), + ); + const queryClient = mount(fetchState); + + expect(screen.getByRole("status").textContent).toBe("1@5"); + + act(() => sockets[0]?.open()); + await waitFor(() => + expect(screen.getByRole("status").textContent).toBe("2@6"), + ); + + act(() => sockets[0]?.push({ reason: "created", revision: 8, unread: 4 })); + act(() => sockets[0]?.push({ reason: "read", revision: 7, unread: 3 })); + expect(screen.getByRole("status").textContent).toBe("4@8"); + + act(() => { + queryClient.setQueryData(SESSION_QUERY_KEY, { + user: { id: USER_ID, notifications: { revision: 6, unread: 2 } }, + }); + }); + expect(screen.getByRole("status").textContent).toBe("4@8"); + }); + + it("re-reads the count after a reconnect, since missed messages are not replayed", async () => { + const fetchState = vi + .fn<() => Promise<{ revision: number; unread: number }>>() + .mockResolvedValueOnce({ revision: 5, unread: 1 }) + .mockResolvedValueOnce({ revision: 12, unread: 0 }); + mount(fetchState); + + act(() => sockets[0]?.open()); + await waitFor(() => expect(fetchState).toHaveBeenCalledTimes(1)); + + act(() => sockets[0]?.drop()); + await waitFor(() => expect(sockets).toHaveLength(2), { timeout: 4_000 }); + act(() => sockets[1]?.open()); + + await waitFor(() => + expect(screen.getByRole("status").textContent).toBe("0@12"), + ); + expect(fetchState).toHaveBeenCalledTimes(2); + }); + + it("polls the count every minute when there is no socket to listen on", () => { + vi.stubEnv("VITNODE_API_URL", SAME_ORIGIN_API_URL); + vi.useFakeTimers(); + const fetchState = vi.fn( + async () => await Promise.resolve({ revision: 9, unread: 3 }), + ); + mount(fetchState); + + expect(sockets).toHaveLength(0); + act(() => { + vi.advanceTimersByTime(60_000); + }); + expect(fetchState).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); +}); diff --git a/packages/vitnode/src/tanstack/notifications/state-sync.tsx b/packages/vitnode/src/tanstack/notifications/state-sync.tsx new file mode 100644 index 000000000..bb74090ef --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/state-sync.tsx @@ -0,0 +1,114 @@ +import { useQueryClient } from "@tanstack/react-query"; +import React from "react"; + +import type { NotificationStateMessage } from "@/lib/notifications/types"; + +import { createCoalescer } from "@/views/notifications/coalesce"; +import { notificationStateStore } from "@/views/notifications/notification-state-store"; +import { fetchNotificationState } from "@/views/notifications/notifications-query"; +import { notificationsStateChannel } from "@/ws/notifications"; +import { useVitNodeWebSocket } from "@/ws/use-websocket"; + +import { useSessionQuery } from "../auth/session-query"; +import { invalidateNotificationLists } from "./cache"; + +const STALE_AFTER_HIDDEN_MS = 30_000; +const POLL_WITHOUT_SOCKET_MS = 60_000; +const SOCKET_OPEN = 1; + +export const NotificationStateSync = ({ + fetchState = fetchNotificationState, +}: { + fetchState?: () => Promise<{ revision: number; unread: number }>; +} = {}) => { + const queryClient = useQueryClient(); + const { data: session } = useSessionQuery(); + const user = session?.user ?? null; + const userId = user?.id ?? null; + + const userIdRef = React.useRef(userId); + React.useEffect(() => { + userIdRef.current = userId; + }); + + React.useEffect(() => { + if (userId === null) notificationStateStore.reset(); + }, [userId]); + + const sessionState = user?.notifications; + React.useEffect(() => { + if (userId !== null && sessionState) { + notificationStateStore.apply(userId, sessionState); + } + }, [sessionState, userId]); + + const coalescerRef = React.useRef>( + null, + ); + React.useEffect(() => { + const coalescer = createCoalescer( + () => { + const current = userIdRef.current; + if (current !== null) { + void invalidateNotificationLists(queryClient, current); + } + }, + { maxWait: 5_000, wait: 1_000 }, + ); + coalescerRef.current = coalescer; + + return () => { + coalescer.cancel(); + coalescerRef.current = null; + }; + }, [queryClient]); + + const refresh = React.useEffectEvent(async () => { + if (userId === null) return; + const latest = await fetchState().catch(() => null); + if (latest && notificationStateStore.apply(userId, latest)) { + coalescerRef.current?.schedule(); + } + }); + + const { readyState } = useVitNodeWebSocket(notificationsStateChannel, { + onMessage: (message: NotificationStateMessage) => { + const current = userIdRef.current; + if (current === null) return; + if (notificationStateStore.apply(current, message)) { + coalescerRef.current?.schedule(); + } + }, + }); + + React.useEffect(() => { + if (readyState === SOCKET_OPEN) void refresh(); + }, [readyState]); + + React.useEffect(() => { + if (readyState === SOCKET_OPEN || userId === null) return; + + const timer = setInterval(() => { + if (document.visibilityState === "visible") void refresh(); + }, POLL_WITHOUT_SOCKET_MS); + + return () => clearInterval(timer); + }, [readyState, userId]); + + React.useEffect(() => { + let hiddenAt: null | number = null; + const onVisibility = () => { + if (document.visibilityState === "hidden") { + hiddenAt = Date.now(); + } else if (hiddenAt !== null) { + if (Date.now() - hiddenAt > STALE_AFTER_HIDDEN_MS) void refresh(); + hiddenAt = null; + } + }; + document.addEventListener("visibilitychange", onVisibility); + + return () => document.removeEventListener("visibilitychange", onVisibility); + }, []); + + return null; +}; diff --git a/packages/vitnode/src/tanstack/realtime/realtime-listeners.tsx b/packages/vitnode/src/tanstack/realtime/realtime-listeners.tsx index f943e4981..51f66045a 100644 --- a/packages/vitnode/src/tanstack/realtime/realtime-listeners.tsx +++ b/packages/vitnode/src/tanstack/realtime/realtime-listeners.tsx @@ -2,6 +2,7 @@ import { NotificationListener } from "@/views/layouts/theme/notification-listene import { WebSocketAuthSync } from "@/views/layouts/theme/web-socket-auth-sync"; import { useSessionQuery } from "../auth/session-query"; +import { NotificationStateSync } from "../notifications/state-sync"; import { socketUserIdFromSession } from "./session"; export const RealtimeListeners = () => { @@ -10,6 +11,7 @@ export const RealtimeListeners = () => { return ( <> + ); diff --git a/packages/vitnode/src/tanstack/settings/overview.tsx b/packages/vitnode/src/tanstack/settings/overview.tsx index a17899767..96462c40d 100644 --- a/packages/vitnode/src/tanstack/settings/overview.tsx +++ b/packages/vitnode/src/tanstack/settings/overview.tsx @@ -2,9 +2,11 @@ import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import React from "react"; import type { UpdatePersonalInformation } from "@/views/auth/settings/overview/personal-update"; +import type { UpdateTimeZone } from "@/views/auth/settings/overview/time-zone-update"; import { OverviewSettingsContent } from "@/views/auth/settings/overview/overview"; import { updatePersonalInformationInBrowser } from "@/views/auth/settings/overview/personal-update"; +import { updateTimeZoneInBrowser } from "@/views/auth/settings/overview/time-zone-update"; import { SSO_CONNECTIONS_IDENTITY_ROOT } from "@/views/auth/settings/sso/sso-connections-query"; import { userProfileQueryKey } from "@/views/profile/profile-query"; @@ -46,12 +48,23 @@ export const OverviewSettings = ({ nameCode }: { nameCode: string }) => { [queryClient, refresh], ); + const onTimeZoneUpdate: UpdateTimeZone = React.useCallback( + async timeZone => { + const result = await updateTimeZoneInBrowser(timeZone); + if (result.data) await invalidateSession(queryClient); + + return result; + }, + [queryClient], + ); + if (!session.user) return null; return ( { name: session.user.name, phone: session.user.phone, showRealName: session.user.showRealName, + timeZone: session.user.timeZone, }} /> ); diff --git a/packages/vitnode/src/tests/admin-user.ts b/packages/vitnode/src/tests/admin-user.ts new file mode 100644 index 000000000..39eeb3595 --- /dev/null +++ b/packages/vitnode/src/tests/admin-user.ts @@ -0,0 +1,35 @@ +import type { AdminUserDetail } from "@/views/admin/views/core/users/detail/user-query"; + +const MEMBER_ROLE = { color: null, id: 3, name: [] }; + +export const adminUserFixture = ( + overrides: Partial = {}, +): AdminUserDetail => ({ + avatarColor: "#123456", + avatarUrl: null, + birthday: null, + coverUrl: null, + createdAt: "2026-01-01T00:00:00.000Z", + email: "moderator@example.com", + emailVerified: true, + firstName: null, + headline: null, + id: 7, + imagePolicy: { + avatar: { allowed: true, maxBytes: 1_000_000 }, + cover: { allowed: true, maxBytes: 1_000_000 }, + }, + isStaff: false, + language: "en", + lastName: null, + name: "Moderator", + nameCode: "moderator", + newsletter: false, + phone: null, + role: MEMBER_ROLE, + roleId: MEMBER_ROLE.id, + secondaryRoles: [], + showRealName: false, + timeZone: null, + ...overrides, +}); diff --git a/packages/vitnode/src/tests/memory-db.ts b/packages/vitnode/src/tests/memory-db.ts index 3ca798475..fc5a51d64 100644 --- a/packages/vitnode/src/tests/memory-db.ts +++ b/packages/vitnode/src/tests/memory-db.ts @@ -417,6 +417,9 @@ const evaluate = ( if (["bigint", "int", "integer", "numeric"].includes(node.to)) { return Number(value); } + if (node.to === "jsonb") { + return typeof value === "string" ? JSON.parse(value) : value; + } return unsupported(`a cast to ${node.to}`); } @@ -895,6 +898,7 @@ export const createMemoryDb = (seed: [Table, readonly object[]][] = []) => { const query = { ...thenable(run), + for: () => query, from: (from: Table) => { source = from; diff --git a/packages/vitnode/src/tests/notifications.ts b/packages/vitnode/src/tests/notifications.ts new file mode 100644 index 000000000..6bbe569df --- /dev/null +++ b/packages/vitnode/src/tests/notifications.ts @@ -0,0 +1,259 @@ +import type { Context } from "hono"; +import type { ReactElement } from "react"; + +import { and, eq, lte } from "drizzle-orm"; +import { createTranslator } from "use-intl"; + +import type { AnyNotificationTypeDefinition } from "@/api/lib/notifications/registry"; +import type { EnvVitNode } from "@/api/middlewares/global.middleware"; +import type { NotificationEmailProps } from "@/emails/notification"; + +import { + type NotificationWorkerSettings, + resolveNotificationWorkerSettings, +} from "@/api/lib/notifications/preferences"; +import { createNotificationRegistry } from "@/api/lib/notifications/registry"; +import { NotificationsModel } from "@/api/models/notifications"; +import { QueueModel } from "@/api/models/queue"; +import { + notificationsCleanupTask, + notificationsEmailTask, + notificationsFanoutTask, +} from "@/api/modules/notifications/tasks/notification-tasks"; +import { processQueueTasksByIds } from "@/api/modules/queue/helpers/process-queue-tasks"; +import { core_files } from "@/database/files"; +import { core_languages } from "@/database/languages"; +import * as notificationTables from "@/database/notifications"; +import { core_queue } from "@/database/queue"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import apiMessages from "@/locales/api/en.json"; + +import { createTestCache } from "./cache"; +import { createTestDatabase, type TestDatabaseHandle } from "./postgres"; + +export interface RealtimeRecord { + data: { reason: string; revision: number; unread: number }; + userId: number; +} + +export interface SentEmailRecord { + entries: NotificationEmailProps["entries"]; + idempotencyKey?: string; + subject: string; + to: string; +} + +export interface NotificationsHarness { + c: Context; + createUsers: (count: number, language?: string) => Promise; + database: TestDatabaseHandle; + drainQueue: (now?: Date) => Promise; + emitted: { name: string; payload: unknown }[]; + forkContext: () => Context; + queryCount: () => number; + realtime: RealtimeRecord[]; + sentEmails: SentEmailRecord[]; + setEmailFailure: (error: Error | null) => void; +} + +const notificationTasks = [ + notificationsFanoutTask, + notificationsEmailTask, + notificationsCleanupTask, +].map(task => ({ + ...task, + module: "notifications", + pluginId: "@vitnode/core", +})); + +export const createNotificationsHarness = async ({ + email = true, + messages = {}, + types, + workers, +}: { + email?: boolean; + messages?: Record; + types: AnyNotificationTypeDefinition[]; + workers?: Partial; +}): Promise => { + let queries = 0; + const database = await createTestDatabase( + { + core_files, + core_languages, + core_queue, + core_roles, + core_users, + ...notificationTables, + }, + { onQuery: () => (queries += 1) }, + ); + + await database.db + .insert(core_languages) + .values({ code: "en", name: "English", timezone: "UTC" }); + await database.db.insert(core_roles).values({ id: 1 }); + + const realtime: RealtimeRecord[] = []; + const emitted: NotificationsHarness["emitted"] = []; + const sentEmails: SentEmailRecord[] = []; + let emailFailure: Error | null = null; + const registry = createNotificationRegistry( + types.map(definition => ({ definition, pluginId: "@acme/test" })), + ); + const translator = createTranslator({ + locale: "en", + messages: { ...apiMessages, ...messages }, + }); + + const makeContext = (db: TestDatabaseHandle["db"]): Context => { + const vars = new Map(); + const c = { + get: (key: string) => vars.get(key), + set: (key: string, value: unknown) => vars.set(key, value), + } as unknown as Context; + + vars.set("db", db); + vars.set("cache", createTestCache()); + vars.set("user", null); + vars.set("admin", null); + vars.set("core", { + email: email + ? { adapter: { sendEmail: async () => await Promise.resolve() } } + : undefined, + metadata: { title: "Test" }, + notifications: registry, + notificationWorkers: resolveNotificationWorkerSettings(workers), + queue: notificationTasks, + }); + vars.set("realtime", { + broadcast: () => undefined, + sendToUser: ( + userId: number, + _channel: unknown, + data: RealtimeRecord["data"], + ) => { + realtime.push({ data, userId }); + }, + }); + vars.set("i18n", { + getTranslator: async () => await Promise.resolve(translator), + resolveSupportedLocale: () => "en", + }); + vars.set("email", { + build: async (args: { + content: (props: unknown) => ReactElement; + subject: string; + to: string; + }) => { + const element = args.content({ + i18n: { locale: "en", messages: {} }, + templateProps: { + metadata: { title: "Test", url: "http://localhost" }, + }, + }); + + return await Promise.resolve({ + entries: element.props.entries, + html: "", + subject: args.subject, + text: "", + to: args.to, + }); + }, + deliver: async ( + built: Omit, + options: { idempotencyKey?: string }, + ) => { + if (emailFailure) throw emailFailure; + sentEmails.push({ ...built, idempotencyKey: options.idempotencyKey }); + + return await Promise.resolve({ id: `message-${sentEmails.length}` }); + }, + }); + vars.set("log", { + debug: async () => {}, + error: async () => {}, + info: async () => {}, + warn: async () => {}, + }); + vars.set("events", { + emit: async (name: string, payload: unknown) => { + emitted.push({ name, payload }); + + return await Promise.resolve({ + delivered: 0, + eventId: `event-${emitted.length}`, + failures: [], + status: "delivered", + }); + }, + }); + vars.set("queue", new QueueModel(c)); + vars.set("notifications", new NotificationsModel(c)); + + return c; + }; + + const c = makeContext(database.db); + let userSeq = 0; + + return { + c, + createUsers: async (count, language = "en") => { + const rows = await database.db + .insert(core_users) + .values( + Array.from({ length: count }, () => { + userSeq += 1; + + return { + avatarColor: "000000", + email: `user${userSeq}@example.com`, + ipAddress: "127.0.0.1", + language, + name: `User ${userSeq}`, + nameCode: `user-${userSeq}`, + roleId: 1, + }; + }), + ) + .returning({ id: core_users.id }); + + return rows.map(row => row.id); + }, + database, + emitted, + drainQueue: async (now = new Date()) => { + let runs = 0; + for (let round = 0; round < 200; round++) { + const due = await database.db + .select({ id: core_queue.id }) + .from(core_queue) + .where( + and( + eq(core_queue.status, "pending"), + lte(core_queue.availableAt, now), + ), + ); + if (due.length === 0) break; + runs += due.length; + await processQueueTasksByIds( + c, + due.map(row => row.id), + ); + } + + return runs; + }, + forkContext: () => makeContext(database.connect()), + queryCount: () => queries, + realtime, + sentEmails, + setEmailFailure: error => { + emailFailure = error; + }, + }; +}; diff --git a/packages/vitnode/src/tests/postgres.ts b/packages/vitnode/src/tests/postgres.ts new file mode 100644 index 000000000..ee5e81296 --- /dev/null +++ b/packages/vitnode/src/tests/postgres.ts @@ -0,0 +1,84 @@ +import { + generateDrizzleJson, + generateMigration, +} from "drizzle-kit/api-postgres"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { describe } from "vitest"; + +const TEST_POSTGRES_URL = process.env.VITNODE_TEST_POSTGRES_URL; + +export const describePostgres = (name: string, body: () => void): void => { + describe.skipIf(!TEST_POSTGRES_URL)(name, body); +}; + +type TestDatabase = ReturnType; + +export interface TestDatabaseHandle { + connect: () => TestDatabase; + db: TestDatabase; + drop: () => Promise; +} + +const withDatabase = (url: string, database: string): string => { + const parsed = new URL(url); + parsed.pathname = `/${database}`; + + return parsed.toString(); +}; + +export const createTestDatabase = async ( + schema: Record, + { onQuery }: { onQuery?: () => void } = {}, +): Promise => { + if (!TEST_POSTGRES_URL) { + throw new Error("VITNODE_TEST_POSTGRES_URL is not set."); + } + + const name = `vitnode_test_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const admin = postgres(TEST_POSTGRES_URL, { max: 1, onnotice: () => {} }); + await admin.unsafe(`CREATE DATABASE "${name}"`); + await admin.end(); + + const url = withDatabase(TEST_POSTGRES_URL, name); + const statements = await generateMigration( + await generateDrizzleJson({}), + await generateDrizzleJson(schema), + ); + + const setup = postgres(url, { max: 1, onnotice: () => {} }); + await setup.unsafe("SET TIME ZONE 'UTC'"); + for (const statement of statements) { + await setup.unsafe(statement); + } + await setup.end(); + + const clients: ReturnType[] = []; + const connect = (): TestDatabase => { + const client = postgres(url, { + connection: { TimeZone: "UTC" }, + max: 10, + onnotice: () => {}, + }); + clients.push(client); + + return drizzle({ + client, + logger: onQuery ? { logQuery: () => onQuery() } : undefined, + }); + }; + + return { + connect, + db: connect(), + drop: async () => { + await Promise.all(clients.map(async client => await client.end())); + const cleanup = postgres(TEST_POSTGRES_URL, { + max: 1, + onnotice: () => {}, + }); + await cleanup.unsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`); + await cleanup.end(); + }, + }; +}; diff --git a/packages/vitnode/src/tests/sessions.ts b/packages/vitnode/src/tests/sessions.ts index a31019b18..aa076b95e 100644 --- a/packages/vitnode/src/tests/sessions.ts +++ b/packages/vitnode/src/tests/sessions.ts @@ -2,6 +2,7 @@ import type { Table } from "drizzle-orm"; import type { Context, Next } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; +import { vi } from "vitest"; import type { EnvVariablesVitNode, @@ -175,6 +176,7 @@ export const createSessionWorld = async ({ }; }; + const emit = vi.fn(async () => Promise.resolve(undefined)); const app = new OpenAPIHono(); app.use("*", async (c: Context, next: Next) => { c.set("core", { @@ -182,6 +184,7 @@ export const createSessionWorld = async ({ } as unknown as EnvVariablesVitNode["core"]); c.set("db", db as unknown as EnvVariablesVitNode["db"]); c.set("cache", cache); + c.set("events", { emit } as unknown as EnvVariablesVitNode["events"]); c.set("ipAddress", "203.0.113.7"); c.set("user", await new SessionModel(c).getUser()); await next(); @@ -219,5 +222,5 @@ export const createSessionWorld = async ({ }; }; - return { app, cache, isCached, probe, rows, rowsFor }; + return { app, cache, emit, isCached, probe, rows, rowsFor }; }; diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts index 9b464d2d5..2618c1fc3 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts @@ -63,6 +63,7 @@ describe("the core group", () => { "/admin/core/system/integrations", "/admin/core/system/navigation", "/admin/core/system/files", + "/admin/core/system/notifications", "/admin/core/users", "/admin/core/users", "/admin/core/users/roles", diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx index 04eb947c9..731a6c476 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx @@ -177,6 +177,11 @@ const coreNavGroup = (): AdminNavGroupDeclaration => ({ href: "/admin/core/system/files", permission: core("files"), }, + { + title: key("admin.global.nav.system.notifications"), + href: "/admin/core/system/notifications", + permission: core("notifications"), + }, ], }, { diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx index b5d0e367b..eba49448d 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -445,8 +445,10 @@ const ContentFormFields = ({ layout={renderedFields => ( field.name), fields: renderedFields, + files, header: presentation === "page" ? header : undefined, localizedFieldNames: localizedFields, mode: data ? "edit" : "create", @@ -459,6 +461,11 @@ const ContentFormFields = ({ }, singular, title, + translations: opened.map(row => ({ + locale: row.locale, + status: row.status, + updatedAt: row.updatedAt, + })), }} > {Layout ? ( diff --git a/packages/vitnode/src/views/admin/views/content/content-mutation.ts b/packages/vitnode/src/views/admin/views/content/content-mutation.ts index f7e9f7056..738d13c56 100644 --- a/packages/vitnode/src/views/admin/views/content/content-mutation.ts +++ b/packages/vitnode/src/views/admin/views/content/content-mutation.ts @@ -13,6 +13,7 @@ export interface TranslationRow { locale: string; publishedAt?: null | string; status?: string; + updatedAt?: null | string; values: Record; version: number; } diff --git a/packages/vitnode/src/views/admin/views/content/form/context.tsx b/packages/vitnode/src/views/admin/views/content/form/context.tsx index 11b305b5a..e777ff2b2 100644 --- a/packages/vitnode/src/views/admin/views/content/form/context.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/context.tsx @@ -1,6 +1,7 @@ import React from "react"; import type { PageTitleBack } from "@/components/ui/page-title"; +import type { ContentFileFieldValue } from "@/content/files"; export interface ContentFormHeaderValue { back: PageTitleBack; @@ -8,9 +9,17 @@ export interface ContentFormHeaderValue { title: React.ReactNode; } +export interface ContentFormTranslationMeta { + locale: string; + status?: string; + updatedAt?: null | string; +} + export interface ContentFormContextValue { + defaultLocale?: null | string; fieldNames: string[]; fields: Record; + files?: Record; header?: ContentFormHeaderValue; localizedFieldNames: string[]; markHeaderRendered?: () => void; @@ -26,6 +35,7 @@ export interface ContentFormContextValue { singular: string; skeleton?: boolean; title?: string; + translations?: readonly ContentFormTranslationMeta[]; } const ContentFormContext = React.createContext( diff --git a/packages/vitnode/src/views/admin/views/content/form/index.ts b/packages/vitnode/src/views/admin/views/content/form/index.ts index 45d44cebc..10a8f9a3b 100644 --- a/packages/vitnode/src/views/admin/views/content/form/index.ts +++ b/packages/vitnode/src/views/admin/views/content/form/index.ts @@ -1,6 +1,7 @@ export { type ContentFormContextValue, type ContentFormHeaderValue, + type ContentFormTranslationMeta, useContentForm, useContentFormOptional, } from "./context"; @@ -22,3 +23,5 @@ export { ContentFormFieldSkeleton, type ContentFormSkeletonControl, } from "./skeleton"; +export { ContentFormStatusSwitch } from "./status-switch"; +export { useContentFormValues, useSetContentFormValue } from "./values"; diff --git a/packages/vitnode/src/views/admin/views/content/form/primitives.tsx b/packages/vitnode/src/views/admin/views/content/form/primitives.tsx index 9214d7b66..f4c7c9529 100644 --- a/packages/vitnode/src/views/admin/views/content/form/primitives.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/primitives.tsx @@ -16,7 +16,13 @@ import { ContentFormStatusSkeleton, } from "./skeleton"; -export const ContentFormSubmit = ({ label }: { label?: React.ReactNode }) => { +export const ContentFormSubmit = ({ + label, + withPublicationToggle = true, +}: { + label?: React.ReactNode; + withPublicationToggle?: boolean; +}) => { const tContent = useTranslations("core.content"); const { mode, publication, skeleton } = useContentForm(); @@ -51,7 +57,9 @@ export const ContentFormSubmit = ({ label }: { label?: React.ReactNode }) => { return ( <> - {mode === "edit" ? : null} + {mode === "edit" && withPublicationToggle ? ( + + ) : null} {label ?? tContent(mode === "create" ? "create.submit" : "edit.submit")} @@ -142,10 +150,12 @@ export const ContentFormActions = ({ children, className, submitLabel, + withPublicationToggle, ...props }: React.ComponentProps<"div"> & { cancelHref?: string; submitLabel?: React.ReactNode; + withPublicationToggle?: boolean; }) => { const t = useTranslations("core.global"); @@ -164,7 +174,10 @@ export const ContentFormActions = ({ {t("cancel")} ) : null} - +
); }; diff --git a/packages/vitnode/src/views/admin/views/content/form/status-switch.test.tsx b/packages/vitnode/src/views/admin/views/content/form/status-switch.test.tsx new file mode 100644 index 000000000..1423e6cda --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/status-switch.test.tsx @@ -0,0 +1,130 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { describe, expect, it, vi } from "vitest"; + +import { type ContentFormContextValue, ContentFormProvider } from "./context"; +import { ContentFormStatusSwitch } from "./status-switch"; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +const renderSwitch = ( + publication: Partial = {}, +) => { + const transition = vi.fn(async () => Promise.resolve(true)); + + render( + + + , + { wrapper }, + ); + + return { transition }; +}; + +describe("ContentFormStatusSwitch", () => { + it("checks the current status", () => { + renderSwitch(); + + expect( + screen + .getByRole("radio", { name: "core.content.status.published" }) + .getAttribute("aria-checked"), + ).toBe("true"); + expect( + screen + .getByRole("radio", { name: "core.content.status.draft" }) + .getAttribute("aria-checked"), + ).toBe("false"); + }); + + it("asks before moving a published item to drafts", async () => { + const { transition } = renderSwitch(); + + fireEvent.click( + screen.getByRole("radio", { name: "core.content.status.draft" }), + ); + + expect( + await screen.findByText("core.content.unpublish.title"), + ).toBeTruthy(); + expect(transition).not.toHaveBeenCalled(); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.unpublish.confirm" }), + ); + + await waitFor(() => { + expect(transition).toHaveBeenCalledWith("unpublish"); + }); + }); + + it("publishes a draft after confirmation", async () => { + const { transition } = renderSwitch({ status: "draft" }); + + fireEvent.click( + screen.getByRole("radio", { name: "core.content.status.published" }), + ); + fireEvent.click( + await screen.findByRole("button", { + name: "core.content.publish.confirm", + }), + ); + + await waitFor(() => { + expect(transition).toHaveBeenCalledWith("publish"); + }); + }); + + it("does nothing for staff without the publish permission", () => { + const { transition } = renderSwitch({ canPublish: false }); + + fireEvent.click( + screen.getByRole("radio", { name: "core.content.status.draft" }), + ); + + expect(screen.queryByText("core.content.unpublish.title")).toBeNull(); + expect(transition).not.toHaveBeenCalled(); + }); + + it("renders nothing while creating", () => { + render( + + + , + { wrapper }, + ); + + expect(screen.queryByRole("radiogroup")).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/form/status-switch.tsx b/packages/vitnode/src/views/admin/views/content/form/status-switch.tsx new file mode 100644 index 000000000..23219f042 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/status-switch.tsx @@ -0,0 +1,140 @@ +import { cn } from "cn"; +import { EyeOffIcon, SendIcon } from "lucide-react"; +import React from "react"; +import { useFormatter, useTranslations } from "use-intl"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { isContentPublished } from "@/content/publication"; + +import { useContentForm } from "./context"; +import { ContentFormButtonSkeleton } from "./skeleton"; + +type StatusOption = "draft" | "published"; + +const OPTIONS: StatusOption[] = ["draft", "published"]; + +export const ContentFormStatusSwitch = ({ + className, +}: { + className?: string; +}) => { + const t = useTranslations("core.content.status"); + const tContent = useTranslations("core.content"); + const format = useFormatter(); + const { mode, publication, singular, skeleton, title } = useContentForm(); + const [pending, setPending] = React.useState(null); + const buttonsRef = React.useRef<(HTMLButtonElement | null)[]>([]); + + if (!publication.enabled || mode === "create") return null; + if (skeleton) return ; + + const current: StatusOption = isContentPublished(publication.status) + ? "published" + : "draft"; + const { transition } = publication; + const disabled = !publication.canPublish || !transition; + const action = pending === "published" ? "publish" : "unpublish"; + const publishedAt = + typeof publication.publishedAt === "string" + ? new Date(publication.publishedAt) + : null; + + const choose = (option: StatusOption) => { + if (option !== current && !disabled) setPending(option); + }; + + const onKeyDown = (event: React.KeyboardEvent, index: number) => { + const step = { ArrowDown: 1, ArrowLeft: -1, ArrowRight: 1, ArrowUp: -1 }[ + event.key + ]; + if (!step) return; + event.preventDefault(); + const next = (index + step + OPTIONS.length) % OPTIONS.length; + buttonsRef.current[next]?.focus(); + }; + + return ( + <> + +
+ {OPTIONS.map((option, index) => { + const checked = option === current; + + return ( + + ); + })} +
+
+ + ( + + {title ?? singular} + + ), + })} + icon={action === "publish" ? : } + onOpenChange={open => { + if (!open) setPending(null); + }} + onSubmit={async ({ onClose }) => { + if (transition && (await transition(action))) onClose(); + }} + open={pending !== null} + submitVariant={action === "publish" ? "default" : "destructive"} + textSubmit={tContent(`${action}.confirm`)} + title={tContent(`${action}.title`, { name: singular })} + /> + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/form/values.ts b/packages/vitnode/src/views/admin/views/content/form/values.ts new file mode 100644 index 000000000..7a9b2b555 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/values.ts @@ -0,0 +1,24 @@ +import { useSelector } from "@tanstack/react-form"; +import React from "react"; + +import { useFormApi } from "@/components/ui/form"; + +export const useContentFormValues = (): Record => { + const { form } = useFormApi(); + + return useSelector( + form.store, + state => state.values as Record, + ); +}; + +export const useSetContentFormValue = () => { + const { form } = useFormApi(); + + return React.useCallback( + (name: string, value: unknown) => { + form.setFieldValue(name, value); + }, + [form], + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-query.ts b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-query.ts index 35bb484c0..e8f2f9db8 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-query.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-query.ts @@ -18,9 +18,9 @@ import { adminQueryRoot } from "@/views/admin/table/query"; export const CRON_ORDER_BY = ["createdAt", "lastRun", "nextRun"] as const; export type CronOrderBy = (typeof CRON_ORDER_BY)[number]; -/** The cron table's URL contract: sortable columns, no search, no filter. */ export const CRON_TABLE_CONTRACT: AdminTableContract = { orderBy: CRON_ORDER_BY, + search: true, }; export type CronParams = AdminTableParams; @@ -33,6 +33,7 @@ export interface CronJobRow { module: string; name: string; nextRun: Date | null | string; + overdue: boolean; pluginId: string; schedule: string; } @@ -83,3 +84,36 @@ export const cronQueryOptions = ({ /** {@link OPERATIONAL_STALE_TIME} - A cron job's next run and last result move without anybody pressing anything. */ staleTime: OPERATIONAL_STALE_TIME, }); + +export interface CronHealth { + active: boolean; + jobs: number; + lastRun: null | string; + nextRun: null | string; + overdueJobs: number; + secretRejected: boolean; + stale: boolean; +} + +export const fetchCronHealth = async (): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/advanced/cron", + path: "/health", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, "the cron health"); + } + + return await response.json(); +}; + +export const cronHealthQueryOptions = () => + queryOptions({ + queryFn: fetchCronHealth, + queryKey: [...cronQueryRoot, "health"] as const, + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-scheduler-status.test.tsx b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-scheduler-status.test.tsx new file mode 100644 index 000000000..a939c89aa --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-scheduler-status.test.tsx @@ -0,0 +1,110 @@ +import { render as renderDom, screen } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { describe, expect, it } from "vitest"; + +import type { CronHealth } from "./cron-query"; + +import { CronSchedulerStatus } from "./cron-scheduler-status"; + +const NAMESPACE = "admin.advanced.cron.health"; + +const render = (ui: React.ReactElement) => + renderDom( + + {ui} + , + ); + +const health = (overrides: Partial): CronHealth => ({ + active: true, + jobs: 5, + lastRun: "2026-10-04T15:34:00Z", + nextRun: "2026-10-04T15:40:00Z", + overdueJobs: 0, + secretRejected: false, + stale: false, + ...overrides, +}); + +describe("CronSchedulerStatus", () => { + it("reports every job on schedule", () => { + render(); + + expect( + screen.getByRole("heading", { name: `${NAMESPACE}.ok.title` }), + ).toBeTruthy(); + expect(screen.getByText("5")).toBeTruthy(); + expect(screen.queryByText(`${NAMESPACE}.stale.help`)).toBeNull(); + }); + + it("calls out overdue jobs while the scheduler still ticks", () => { + render(); + + expect( + screen.getByRole("heading", { name: `${NAMESPACE}.overdue.title` }), + ).toBeTruthy(); + expect(screen.getByText(`${NAMESPACE}.overdue.desc`)).toBeTruthy(); + }); + + it("reports a stopped scheduler with help and no next run", () => { + render( + , + ); + + expect( + screen.getByRole("heading", { name: `${NAMESPACE}.stale.title` }), + ).toBeTruthy(); + expect(screen.getByText(`${NAMESPACE}.stale.desc`)).toBeTruthy(); + expect(screen.getByText(`${NAMESPACE}.stale.help`)).toBeTruthy(); + expect(screen.getByText("—")).toBeTruthy(); + expect( + screen.queryByText(`${NAMESPACE}.no_adapter`, { exact: false }), + ).toBeNull(); + }); + + it("explains a missing adapter when no job has ever run", () => { + render( + , + ); + + expect(screen.getByText(`${NAMESPACE}.stale.desc_never`)).toBeTruthy(); + expect( + screen.getByText(`${NAMESPACE}.no_adapter`, { exact: false }), + ).toBeTruthy(); + expect(screen.getByText(`${NAMESPACE}.facts.never`)).toBeTruthy(); + }); + + it("names a rejected CRON_SECRET as the reason nothing runs", () => { + render( + , + ); + + expect( + screen.getByText(`${NAMESPACE}.secret_rejected`, { exact: false }), + ).toBeTruthy(); + expect( + screen.queryByText(`${NAMESPACE}.no_adapter`, { exact: false }), + ).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-scheduler-status.tsx b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-scheduler-status.tsx new file mode 100644 index 000000000..9dafca471 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-scheduler-status.tsx @@ -0,0 +1,165 @@ +import { cn } from "cn"; +import { CircleCheckIcon, CircleXIcon, TriangleAlertIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { DOCS_URLS } from "@/lib/docs-links"; + +import type { CronHealth } from "./cron-query"; + +type SchedulerState = "ok" | "overdue" | "stopped"; + +const STATE_ICON = { + ok: CircleCheckIcon, + overdue: TriangleAlertIcon, + stopped: CircleXIcon, +} as const; + +const STATE_TONE = { + ok: "bg-success/10 text-success", + overdue: "bg-warn/10 text-warn", + stopped: "bg-destructive/10 text-destructive", +} as const; + +const DocsLink = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +const Fact = ({ + children, + label, +}: { + children: React.ReactNode; + label: string; +}) => ( +
+
{label}
+
{children}
+
+); + +const schedulerStateOf = (health: CronHealth): SchedulerState => { + if (health.stale) return "stopped"; + + return health.overdueJobs > 0 ? "overdue" : "ok"; +}; + +const StatusTitle = ({ + health, + state, +}: { + health: CronHealth; + state: SchedulerState; +}) => { + const t = useTranslations("admin.advanced.cron.health"); + + if (state === "stopped") return t("stale.title"); + if (state === "overdue") { + return t("overdue.title", { count: health.overdueJobs }); + } + + return t("ok.title"); +}; + +const StatusDescription = ({ + lastRun, + state, +}: { + lastRun: CronHealth["lastRun"]; + state: SchedulerState; +}) => { + const t = useTranslations("admin.advanced.cron.health"); + + if (state === "overdue") return t("overdue.desc"); + if (state === "ok") return t("ok.desc"); + if (!lastRun) return t("stale.desc_never"); + + return ( + <> + {t.rich("stale.desc", { + date: () => , + })} + + ); +}; + +const StatusHelp = ({ health }: { health: CronHealth }) => { + const t = useTranslations("admin.advanced.cron.health"); + + return ( +

+ {health.secretRejected ? ( + <>{t("secret_rejected")} + ) : ( + !health.active && <>{t("no_adapter")} + )} + {t.rich("stale.help", { + docs: text => {text}, + })} +

+ ); +}; + +export const CronSchedulerStatus = ({ health }: { health: CronHealth }) => { + const t = useTranslations("admin.advanced.cron.health"); + const state = schedulerStateOf(health); + const Icon = STATE_ICON[state]; + const lastRun = health.lastRun; + + return ( +
+
+ + + + +
+

+ +

+ +

+ +

+ + {(state === "stopped" || health.secretRejected) && ( + + )} +
+
+ +
+ + {lastRun ? : t("facts.never")} + + + {health.nextRun && !health.stale ? ( + + ) : ( + "—" + )} + + {health.jobs} +
+
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-content.tsx b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-content.tsx index d0e665904..c72baf2a5 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-content.tsx @@ -1,13 +1,50 @@ +import { CircleCheckIcon, ClockIcon, TriangleAlertIcon } from "lucide-react"; import { useTranslations } from "use-intl"; import { DateFormat } from "@/components/date-format"; import { ContentDataTable } from "@/components/table/content"; +import { Badge } from "@/components/ui/badge"; import type { CronJobRow, CronPage } from "./cron-query"; import type { RunCron } from "./run-action/run-cron"; import { RunActionCronTable } from "./run-action/run-action"; +type CronJobStatus = "on_schedule" | "overdue" | "waiting"; + +const jobStatus = (row: CronJobRow): CronJobStatus => { + if (row.overdue) return "overdue"; + if (!row.lastRun) return "waiting"; + + return "on_schedule"; +}; + +const CronStatusBadge = ({ status }: { status: CronJobStatus }) => { + const t = useTranslations("admin.advanced.cron.list.status"); + + if (status === "on_schedule") { + return ( + + {t(status)} + + ); + } + + if (status === "overdue") { + return ( + + {t(status)} + + ); + } + + return ( + + {t(status)} + + ); +}; + export const CronTableContent = ({ data, onRun, @@ -35,11 +72,31 @@ export const CronTableContent = ({
), }, - { accessorKey: "pluginId", header: t("list.pluginId") }, - { accessorKey: "module", header: t("list.module") }, + { + id: "status", + header: t("list.status.title"), + cell: ({ row }) => , + }, + { + accessorKey: "pluginId", + header: t("list.plugin"), + cell: ({ row }) => ( +
+ {row.pluginId} + + {row.module} + +
+ ), + }, { accessorKey: "schedule", header: t("list.schedule"), + cell: ({ row }) => ( + + {row.schedule} + + ), }, { accessorKey: "lastRun", @@ -82,6 +139,8 @@ export const CronTableContent = ({ }, }} pageInfo={data.pageInfo} + search + searchPlaceholder={t("list.search")} /> ); }; diff --git a/packages/vitnode/src/views/admin/views/core/advanced/queue/badges/status-badge.tsx b/packages/vitnode/src/views/admin/views/core/advanced/queue/badges/status-badge.tsx index bfe8c33f9..a9c0c7ad4 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/queue/badges/status-badge.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/queue/badges/status-badge.tsx @@ -15,7 +15,10 @@ export const QueueStatusBadge = ({ status }: { status: QueueTaskStatus }) => { if (status === "processing") { return ( - + {t("processing")} ); @@ -23,7 +26,7 @@ export const QueueStatusBadge = ({ status }: { status: QueueTaskStatus }) => { if (status === "completed") { return ( - + {t("completed")} ); @@ -31,7 +34,7 @@ export const QueueStatusBadge = ({ status }: { status: QueueTaskStatus }) => { if (status === "failed") { return ( - + {t("failed")} ); diff --git a/packages/vitnode/src/views/admin/views/core/debug/system-logs/badges/badge-type-log.tsx b/packages/vitnode/src/views/admin/views/core/debug/system-logs/badges/badge-type-log.tsx index 5c3d0b5f4..907a1117b 100644 --- a/packages/vitnode/src/views/admin/views/core/debug/system-logs/badges/badge-type-log.tsx +++ b/packages/vitnode/src/views/admin/views/core/debug/system-logs/badges/badge-type-log.tsx @@ -12,7 +12,7 @@ export const BadgeTypeLog = ({ if (type === "warn") { return ( - + {t(type)} ); @@ -20,7 +20,7 @@ export const BadgeTypeLog = ({ if (type === "error") { return ( - + {t(type)} ); diff --git a/packages/vitnode/src/views/admin/views/core/shared/admin-permission-parity.test.ts b/packages/vitnode/src/views/admin/views/core/shared/admin-permission-parity.test.ts index 7de42ff66..afdc3d832 100644 --- a/packages/vitnode/src/views/admin/views/core/shared/admin-permission-parity.test.ts +++ b/packages/vitnode/src/views/admin/views/core/shared/admin-permission-parity.test.ts @@ -67,13 +67,25 @@ const key = ({ }): string => `${module}:${permission}`; describe("the scan is reading real declarations", () => { - it("finds the eight users routes that declare one", () => { - expect([...declarationsIn("users").keys()]).toEqual([ + it("finds the twenty users routes that declare one", () => { + expect([...declarationsIn("users").keys()].sort()).toEqual([ "create.route.ts", + "devices.route.ts", "image-delete.route.ts", "image-upload.route.ts", "list.route.ts", + "notification-preferences-update.route.ts", + "notification-preferences.route.ts", + "passkey-delete.route.ts", + "passkey-rename.route.ts", + "passkeys.route.ts", + "password.route.ts", + "revoke-device.route.ts", + "revoke-devices.route.ts", "show.route.ts", + "sso-disconnect.route.ts", + "sso-preferences.route.ts", + "sso.route.ts", "timeline.route.ts", "update.route.ts", "verify-email.route.ts", diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-content.tsx b/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-content.tsx index 6537aa8b6..a6437fc27 100644 --- a/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-content.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { useTranslations } from "use-intl"; +import { DateFormat } from "@/components/date-format"; import { Skeleton } from "@/components/ui/skeleton"; import { DOCS_URLS } from "@/lib/docs-links"; @@ -25,6 +26,82 @@ import { TestStorageAction } from "./test-storage/test-storage"; const toStatus = (active: boolean): IntegrationStatus => active ? "active" : "inactive"; +const CronMeta = ({ + cron, + failing, +}: { + cron: AdminIntegrations["cron"]; + failing: boolean; +}) => { + const t = useTranslations("admin.system.integrations.cron"); + const lastRun = cron.lastRun; + + if (failing && cron.stale) { + return ( + + {lastRun + ? t.rich("stale", { date: () => }) + : t("stale_never")} + + ); + } + if (failing) { + return ( + + {t("overdue", { count: cron.overdueJobs })} + + ); + } + if (!cron.active) return {t("not_configured")}; + if (!cron.secure) { + return ( + + {t("insecure")} + + ); + } + + return {t("jobs", { count: cron.jobs })}; +}; + +const cronStatusOf = ( + cron: AdminIntegrations["cron"], + failing: boolean, +): IntegrationStatus => { + if (failing) return "warning"; + if (!cron.active) return "inactive"; + + return cron.secure ? "active" : "warning"; +}; + +const CronIntegrationCard = ({ cron }: { cron: AdminIntegrations["cron"] }) => { + const t = useTranslations("admin.system.integrations"); + const hasRun = cron.active || cron.lastRun !== null; + const failing = hasRun && cron.overdueJobs > 0; + const status = cronStatusOf(cron, failing); + + return ( + } + readMoreLabel={t("read_more")} + status={status} + statusLabel={t(`status.${status}`)} + title={t("cron.title")} + /> + ); +}; + +const redisStatusOf = ( + redis: AdminIntegrations["redis"], +): IntegrationStatus => { + if (redis.active) return "active"; + + return redis.configuredButDown ? "warning" : "inactive"; +}; + export const IntegrationsContent = ({ canSendTestEmail, canTestAi, @@ -41,22 +118,12 @@ export const IntegrationsContent = ({ const t = useTranslations("admin.system.integrations"); const statusLabel = (status: IntegrationStatus) => t(`status.${status}`); - const redisStatus: IntegrationStatus = data.redis.active - ? "active" - : data.redis.configuredButDown - ? "warning" - : "inactive"; + const redisStatus = redisStatusOf(data.redis); const contentPreviewStatus: IntegrationStatus = data.contentPreview.active ? "active" : "inactive"; - const cronStatus: IntegrationStatus = !data.cron.active - ? "inactive" - : data.cron.stale || !data.cron.secure - ? "warning" - : "active"; - return (
- {t("cron.not_configured")} - ) : data.cron.stale ? ( - - {t("cron.stale")} - - ) : !data.cron.secure ? ( - - {t("cron.insecure")} - - ) : ( - {t("cron.jobs", { count: data.cron.jobs })} - ) - } - readMoreLabel={t("read_more")} - status={cronStatus} - statusLabel={statusLabel(cronStatus)} - title={t("cron.title")} - /> + + (NOTIFICATION_STATS_RANGES as readonly unknown[]).includes(value); + +const failureRate = ({ failed, sent }: NotificationStatsTotals) => + sent + failed === 0 ? 0 : (failed / (sent + failed)) * 100; + +const valueOf = (totals: NotificationStatsTotals, metric: Metric) => + metric === "failureRate" ? failureRate(totals) : totals[metric]; + +const TONE = { + events: "var(--chart-1)", + failureRate: "var(--destructive)", + sent: "var(--chart-1)", + skipped: "var(--chart-5)", +} as const satisfies Record; + +const SWAP_HIDDEN = { + filter: "blur(2px)", + opacity: 0, + y: 4, +} satisfies TargetAndTransition; + +const SWAP_SHOWN = { + filter: "blur(0px)", + opacity: 1, + y: 0, +} satisfies TargetAndTransition; + +const SWAP_EXIT = { + filter: "blur(2px)", + opacity: 0, + transition: { duration: 0.1, ease: "easeIn" }, + y: -4, +} satisfies TargetAndTransition; + +const SNAPPY_EASE = [0.32, 0.72, 0, 1] as const; + +const SWAP_TRANSITION = { + duration: 0.2, + ease: SNAPPY_EASE, +} satisfies Transition; + +const Swap = ({ + children, + swapKey, +}: { + children: React.ReactNode; + swapKey: string; +}) => { + const shouldReduceMotion = useReducedMotion(); + + return ( + + + + {children} + + + + ); +}; + +type Unit = AdminNotificationStats["unit"]; + +const bucketStart = (key: string) => { + const [date = "", hour] = key.split("T"); + const [year = 1970, month = 1, day = 1] = date.split("-").map(Number); + + return new Date(Date.UTC(year, month - 1, day, hour ? Number(hour) : 0)); +}; + +const SparkTooltip = ({ + metric, + point, + unit, +}: { + metric: Metric; + point: AdminNotificationStats["points"][number]; + unit: Unit; +}) => { + const t = useTranslations("admin.system.notifications.activity.tooltip"); + const format = useFormatter(); + const start = bucketStart(point.key); + const when = + unit === "hour" + ? format.dateTimeRange( + start, + new Date(start.getTime() + 60 * 60 * 1000), + { + day: "numeric", + hour: "numeric", + minute: "2-digit", + month: "short", + timeZone: "UTC", + }, + ) + : format.dateTime(start, { + day: "numeric", + month: "short", + timeZone: "UTC", + weekday: "short", + }); + const attempted = point.sent + point.failed; + const value = + metric === "failureRate" + ? attempted === 0 + ? t("failure_none") + : t("failure", { + failed: point.failed, + rate: format.number(point.failed / attempted, { + maximumFractionDigits: 1, + style: "percent", + }), + total: attempted, + }) + : t(metric, { count: point[metric] }); + + return ( +
+ {when} + + + {value} + +
+ ); +}; + +const SparkTooltipLayer = ({ + children, + glide, + open, + width, + x, +}: { + children: React.ReactNode; + glide: boolean; + open: boolean; + width: number; + x: number; +}) => { + const shouldReduceMotion = useReducedMotion(); + const [size, setSize] = React.useState(0); + const observerRef = React.useRef(null); + const measure = React.useCallback((node: HTMLDivElement | null) => { + observerRef.current?.disconnect(); + if (!node) return; + observerRef.current = new ResizeObserver(([entry]) => { + setSize(entry?.borderBoxSize[0]?.inlineSize ?? node.offsetWidth); + }); + observerRef.current.observe(node); + }, []); + const left = Math.min(Math.max(x - size / 2, 0), Math.max(width - size, 0)); + + return ( + + {children} + + ); +}; + +const SparkTooltipPortal = ({ + container, + glideAllowed, + metric, + points, + unit, +}: { + container: HTMLDivElement | null; + glideAllowed: boolean; + metric: Metric; + points: AdminNotificationStats["points"]; + unit: Unit; +}) => { + const active = useIsTooltipActive(); + const label = useActiveTooltipLabel(); + const x = useActiveTooltipCoordinate()?.x; + const width = useChartWidth() ?? 0; + const key = typeof label === "string" ? label : undefined; + const open = active && key !== undefined; + const [shown, setShown] = React.useState({ + glide: false, + key: undefined as string | undefined, + open: false, + x: 0, + }); + + if ( + open !== shown.open || + (key !== undefined && key !== shown.key) || + (x !== undefined && x !== shown.x) + ) { + setShown({ + glide: shown.open && open, + key: key ?? shown.key, + open, + x: x ?? shown.x, + }); + } + + const point = points.find(item => item.key === shown.key); + if (!container || !point) return null; + + return createPortal( + + + , + container, + ); +}; + +const Sparkline = ({ + animate, + label, + metric, + points, + unit, +}: { + animate: boolean; + label: string; + metric: Metric; + points: AdminNotificationStats["points"]; + unit: Unit; +}) => { + const t = useTranslations("admin.system.notifications.activity"); + const id = React.useId().replace(/:/g, ""); + const [input, setInput] = React.useState<"keyboard" | "pointer">("pointer"); + const [layer, setLayer] = React.useState(null); + const color = TONE[metric]; + const config = { value: { color } } satisfies ChartConfig; + const data = points.map(point => ({ + key: point.key, + value: valueOf(point, metric), + })); + + return ( +
+ { + setInput("keyboard"); + }} + onPointerMove={() => { + setInput("pointer"); + }} + > + + + + + + + + + null} + cursor={{ stroke: "var(--border)", strokeWidth: 1 }} + isAnimationActive={false} + /> + + + + +
+ ); +}; + +const Delta = ({ + current, + goodWhenUp, + previous, +}: { + current: number; + goodWhenUp: boolean; + previous: number; +}) => { + const t = useTranslations("admin.system.notifications.activity"); + const format = useFormatter(); + + if (previous === 0) { + return ( + {t("no_previous")} + ); + } + + const change = (current - previous) / previous; + const up = change >= 0; + const Icon = up ? ArrowUpRightIcon : ArrowDownRightIcon; + + return ( + + + {format.number(Math.abs(change), { + maximumFractionDigits: 1, + style: "percent", + })} + + {t("vs_previous")} + + + ); +}; + +const StatTile = ({ + animate, + goodWhenUp, + label, + metric, + stats, +}: { + animate: boolean; + goodWhenUp: boolean; + label: string; + metric: Metric; + stats: AdminNotificationStats; +}) => { + const format = useFormatter(); + const current = valueOf(stats.totals, metric); + const previous = valueOf(stats.previous, metric); + const value = + metric === "failureRate" + ? format.number(current / 100, { + maximumFractionDigits: 2, + style: "percent", + }) + : format.number(current); + + return ( + + +

{label}

+

+ {value} +

+ + + + +
+
+ ); +}; + +const TilesSkeleton = () => ( +
+ {["a", "b", "c", "d"].map(key => ( + + ))} +
+); + +export const NotificationsActivity = () => { + const t = useTranslations("admin.system.notifications.activity"); + const tPage = useTranslations("admin.system.notifications.load_error"); + const tError = useTranslations("core.global.errors"); + const timeZone = useTimeZone() ?? "UTC"; + const [range, setRange] = React.useState("7d"); + const [hasSwitched, setHasSwitched] = React.useState(false); + const shouldReduceMotion = useReducedMotion(); + const animate = hasSwitched && !shouldReduceMotion; + const query = useQuery({ + ...notificationStatsQueryOptions({ range, timeZone }), + placeholderData: keepPreviousData, + }); + + return ( +
+
+
+

+ {t("title")} +

+

+ {t(`ranges.${range}`)} +

+
+ { + if (!isRange(value)) return; + setRange(value); + setHasSwitched(true); + }} + value={range} + > + + {NOTIFICATION_STATS_RANGES.map(key => ( + + {t(`ranges_short.${key}`)} + + ))} + + +
+ + {query.data ? ( +
+ + + + +
+ ) : query.isError ? ( + + {tPage("title")} + +

{tPage("desc")}

+ +
+
+ ) : ( + + )} +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/delete-all-dialog.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/delete-all-dialog.tsx new file mode 100644 index 000000000..9dc65c731 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/delete-all-dialog.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; + +import type { NotificationsAdminActions } from "./notifications-mutations"; + +export const DeleteAllDialog = ({ + count, + onDelete, + trigger, +}: { + count: number; + onDelete: NotificationsAdminActions["deleteAll"]; + trigger: React.ReactElement; +}) => { + const t = useTranslations("admin.system.notifications.danger.delete_all"); + const tError = useTranslations("core.global.errors"); + const tConfirm = useTranslations("core.global.confirm_action"); + const [open, setOpen] = React.useState(false); + const [isDeleting, setIsDeleting] = React.useState(false); + const phrase = t("phrase"); + const formSchema = z.object({ + confirmation: z + .string({ error: () => t("phrase_error") }) + .refine( + value => value.trim().toLowerCase() === phrase.toLowerCase(), + t("phrase_error"), + ), + }); + + return ( + + + + + {`${t("title")}?`} + + {t("confirm_desc", { count })} + + + ( + ( + + {phrase} + + ), + })} + spellCheck={false} + /> + ), + }, + ]} + formSchema={formSchema} + layout={rendered => ( +
+ {rendered.confirmation} + + + + +
+ )} + onSubmit={async () => { + setIsDeleting(true); + const mutation = await onDelete(); + setIsDeleting(false); + + if (mutation.error !== undefined) { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + + return; + } + + setOpen(false); + toast.success(t("success"), { + description: t("success_desc", { count: mutation.data.items }), + }); + }} + /> +
+
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/notifications-content.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/notifications-content.tsx new file mode 100644 index 000000000..b8939e2fc --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/notifications-content.tsx @@ -0,0 +1,49 @@ +import { PauseIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +import type { NotificationsAdminActions } from "./notifications-mutations"; +import type { AdminNotificationsOverview } from "./notifications-query"; + +import { NotificationsActivity } from "./activity-content"; +import { NotificationsTypesSection } from "./types-content"; + +export interface NotificationsAdminPermissions { + canEdit: boolean; + canManage: boolean; +} + +export const NotificationsContent = ({ + actions, + data, + permissions: { canEdit, canManage }, +}: { + actions: NotificationsAdminActions; + data: AdminNotificationsOverview; + permissions: NotificationsAdminPermissions; +}) => { + const t = useTranslations("admin.system.notifications.paused"); + + return ( +
+ {data.settings.paused ? ( + + + {t("title")} + {t("desc")} + + ) : null} + + + + +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/notifications-mutations.ts b/packages/vitnode/src/views/admin/views/core/system/notifications/notifications-mutations.ts new file mode 100644 index 000000000..30c911e73 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/notifications-mutations.ts @@ -0,0 +1,175 @@ +import type { NotificationEmailMode } from "@/lib/notifications/types"; + +import { CONFIG_PLUGIN } from "@/config"; +import { fetcherClient } from "@/lib/fetcher-client"; + +export type NotificationsMutationResult = + | { data: T; error?: never; status?: never } + | { data?: never; error: string; status: number }; + +export interface NotificationTypePolicyPatch { + allowEmail?: boolean; + allowInApp?: boolean; + allowPush?: boolean; + email?: NotificationEmailMode; + enabled?: boolean; + inApp?: boolean; + memberCanEdit?: boolean; +} + +export interface NotificationsAdminActions { + cancelQueuedEmails: () => Promise< + NotificationsMutationResult<{ cancelled: number }> + >; + deleteAll: () => Promise< + NotificationsMutationResult<{ events: number; items: number }> + >; + markEverythingRead: () => Promise< + NotificationsMutationResult<{ items: number; members: number }> + >; + pause: () => Promise>; + resetMemberPreferences: () => Promise< + NotificationsMutationResult<{ members: number }> + >; + resume: () => Promise< + NotificationsMutationResult<{ requeuedEvents: number }> + >; + sendTestEmail: () => Promise< + NotificationsMutationResult<{ deliveryId: number }> + >; + updateTypePolicy: ( + type: string, + body: NotificationTypePolicyPatch, + ) => Promise>; +} + +const FAILED = { error: "Request failed", status: 500 } as const; + +const failure = async (response: { + status: number; + text: () => Promise; +}) => ({ error: await response.text(), status: response.status }); + +export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { + cancelQueuedEmails: async () => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/notifications", + path: "/emails/cancel", + }); + if (!response.ok) return await failure(response); + + return { data: await response.json() }; + } catch { + return FAILED; + } + }, + deleteAll: async () => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/notifications", + path: "/delete-all", + }); + if (!response.ok) return await failure(response); + + return { data: await response.json() }; + } catch { + return FAILED; + } + }, + markEverythingRead: async () => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/notifications", + path: "/read-all", + }); + if (!response.ok) return await failure(response); + + return { data: await response.json() }; + } catch { + return FAILED; + } + }, + pause: async () => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/notifications", + path: "/pause", + }); + if (!response.ok) return await failure(response); + + return { data: await response.json() }; + } catch { + return FAILED; + } + }, + resetMemberPreferences: async () => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/notifications", + path: "/members/reset-preferences", + }); + if (!response.ok) return await failure(response); + + return { data: await response.json() }; + } catch { + return FAILED; + } + }, + resume: async () => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/notifications", + path: "/resume", + }); + if (!response.ok) return await failure(response); + + return { data: await response.json() }; + } catch { + return FAILED; + } + }, + sendTestEmail: async () => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/notifications", + path: "/test-email", + }); + if (!response.ok) return await failure(response); + + return { data: await response.json() }; + } catch { + return FAILED; + } + }, + updateTypePolicy: async (type, body) => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body, params: { type } }, + method: "put", + module: "admin/notifications", + path: "/types/{type}", + }); + if (!response.ok) return await failure(response); + + return { data: await response.json() }; + } catch { + return FAILED; + } + }, +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/notifications-query.ts b/packages/vitnode/src/views/admin/views/core/system/notifications/notifications-query.ts new file mode 100644 index 000000000..435b96c4c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/notifications-query.ts @@ -0,0 +1,159 @@ +import { queryOptions } from "@tanstack/react-query"; + +import type { NotificationEmailMode } from "@/lib/notifications/types"; + +import { CONFIG_PLUGIN } from "@/config"; +import { OPERATIONAL_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; +import { AdminRequestError } from "@/views/admin/admin-request"; +import { adminQueryRoot } from "@/views/admin/table/query"; + +export interface AdminNotificationSettings { + paused: boolean; +} + +export interface AdminNotificationWorkers { + emailBatchSize: number; + emailConcurrency: number; + fanoutBatchSize: number; + retentionDays: number; +} + +export interface AdminNotificationTypePolicy { + allowEmail: boolean; + allowInApp: boolean; + allowPush: boolean; + email: NotificationEmailMode | null; + enabled: boolean; + inApp: boolean | null; + memberCanEdit: boolean; +} + +export interface AdminNotificationType { + category: string; + defaults: { email: NotificationEmailMode; inApp: boolean }; + emailAvailable: boolean; + emailSupported: boolean; + grouped: boolean; + id: string; + label: string; + mandatory: boolean; + pluginId: string; + policy: AdminNotificationTypePolicy; + version: number; +} + +export interface AdminNotificationsOverview { + counts: { + customizedMembers: number; + inboxItems: number; + queuedEmails: number; + unreadItems: number; + }; + email: { adapterConfigured: boolean }; + health: { + cronActive: boolean; + cronStale: boolean; + deliveries: Record; + events: Record; + oldestPendingEventAt: Date | null | string; + queue: Record; + }; + settings: AdminNotificationSettings; + types: AdminNotificationType[]; + workers: AdminNotificationWorkers; +} + +export const NOTIFICATION_STATS_RANGES = ["24h", "7d", "30d"] as const; +export type NotificationStatsRange = (typeof NOTIFICATION_STATS_RANGES)[number]; + +export interface NotificationStatsTotals { + events: number; + failed: number; + sent: number; + skipped: number; +} + +export interface AdminNotificationStats { + points: (NotificationStatsTotals & { key: string })[]; + previous: NotificationStatsTotals; + range: NotificationStatsRange; + timeZone: string; + totals: NotificationStatsTotals; + unit: "day" | "hour"; +} + +export const fetchNotificationsOverview = + async (): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/notifications", + path: "/overview", + }); + + if (!response.ok) { + throw new AdminRequestError( + response.status, + "the notifications overview", + ); + } + + return await response.json(); + }; + +export const fetchNotificationStats = async ({ + range, + timeZone, +}: { + range: NotificationStatsRange; + timeZone: string; +}): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: { range, timeZone } }, + method: "get", + module: "admin/notifications", + path: "/stats", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, "the notification activity"); + } + + return await response.json(); +}; + +export const notificationsAdminQueryRoot = adminQueryRoot("notifications"); + +export const notificationsOverviewQueryKey = [ + ...notificationsAdminQueryRoot, + "overview", +] as const; + +export const notificationStatsQueryRoot = [ + ...notificationsAdminQueryRoot, + "stats", +] as const; + +export const notificationsOverviewQueryOptions = () => + queryOptions({ + queryFn: async () => await fetchNotificationsOverview(), + queryKey: notificationsOverviewQueryKey, + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); + +export const notificationStatsQueryOptions = ({ + range, + timeZone, +}: { + range: NotificationStatsRange; + timeZone: string; +}) => + queryOptions({ + queryFn: async () => await fetchNotificationStats({ range, timeZone }), + queryKey: [...notificationStatsQueryRoot, range, timeZone] as const, + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/reset-members.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/reset-members.tsx new file mode 100644 index 000000000..a02be6ae3 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/reset-members.tsx @@ -0,0 +1,53 @@ +import { RotateCcwIcon } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { Button } from "@/components/ui/button"; + +import type { NotificationsAdminActions } from "./notifications-mutations"; + +export const ResetMembersButton = ({ + count, + onReset, +}: { + count: number; + onReset: NotificationsAdminActions["resetMemberPreferences"]; +}) => { + const t = useTranslations("admin.system.notifications.reset_members"); + const tError = useTranslations("core.global.errors"); + + return ( + + {t("desc", { count })} + {t("warning")} + + } + onSubmit={async ({ onClose }) => { + const mutation = await onReset(); + + if (mutation.error !== undefined) { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + + return; + } + + onClose(); + toast.success(t("success"), { + description: t("success_desc", { count: mutation.data.members }), + }); + }} + textSubmit={t("submit")} + title={t("title")} + > + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/settings-sheet-content.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/settings-sheet-content.tsx new file mode 100644 index 000000000..8a6bae39a --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/settings-sheet-content.tsx @@ -0,0 +1,368 @@ +import { cn } from "cn"; +import { + CheckCheckIcon, + ChevronRightIcon, + type LucideIcon, + MailCheckIcon, + MailXIcon, + PauseIcon, + PlayIcon, + Trash2Icon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +import type { NotificationsAdminActions } from "./notifications-mutations"; +import type { AdminNotificationsOverview } from "./notifications-query"; + +import { DeleteAllDialog } from "./delete-all-dialog"; + +export interface NotificationsSettingsSheetBodyProps { + actions: NotificationsAdminActions; + counts: AdminNotificationsOverview["counts"]; + emailConfigured: boolean; + settings: AdminNotificationsOverview["settings"]; + workers: AdminNotificationsOverview["workers"]; +} + +const Group = ({ + children, + title, + tone, +}: { + children: React.ReactNode; + title: string; + tone?: "danger"; +}) => { + const id = React.useId(); + + return ( +
+

+ {title} +

+
    + {children} +
+
+ ); +}; + +const RowText = ({ + description, + label, + tone, +}: { + description?: string; + label: string; + tone?: "danger"; +}) => ( + + + {label} + + {description ? ( + + {description} + + ) : null} + +); + +const ActionRow = ({ + description, + Icon, + label, + onRun, +}: { + description: string; + Icon: LucideIcon; + label: string; + onRun: () => Promise; +}) => { + const t = useTranslations("admin.system.notifications.tools"); + const [isRunning, setIsRunning] = React.useState(false); + + return ( +
  • + + + +
  • + ); +}; + +const DangerRow = ({ + confirm, + description, + Icon, + title, + tone = "danger", + trailing, +}: { + confirm: (trigger: React.ReactElement) => React.ReactNode; + description: string; + Icon: LucideIcon; + title: string; + tone?: "danger" | "safe"; + trailing?: React.ReactNode; +}) => ( +
  • + {confirm( + , + )} +
  • +); + +export const NotificationsSettingsSheetBody = ({ + actions, + counts, + emailConfigured, + settings, + workers, +}: NotificationsSettingsSheetBodyProps) => { + const t = useTranslations("admin.system.notifications.settings"); + const tTools = useTranslations("admin.system.notifications.tools"); + const tDanger = useTranslations("admin.system.notifications.danger"); + const tError = useTranslations("core.global.errors"); + + const failed = () => { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + }; + + return ( +
    + {emailConfigured ? ( + + { + const mutation = await actions.sendTestEmail(); + if (mutation.error !== undefined) { + if (mutation.status === 400) { + toast.error(tError("title"), { + description: tTools("test_email.not_configured"), + }); + } else failed(); + + return; + } + toast.success(tTools("test_email.success"), { + description: tTools("test_email.success_desc", { + id: mutation.data.deliveryId, + }), + }); + }} + /> + + ) : null} + + +
  • + {tDanger("intro")} +
  • + {settings.paused ? ( + ( + { + const mutation = await actions.resume(); + if (mutation.error !== undefined) { + failed(); + + return; + } + onClose(); + toast.success(tDanger("resume.success"), { + description: tDanger("resume.success_desc", { + count: mutation.data.requeuedEvents, + }), + }); + }} + submitVariant="default" + textSubmit={tDanger("resume.submit")} + title={`${tDanger("resume.title")}?`} + > + {trigger} + + )} + description={tDanger("resume.desc")} + Icon={PlayIcon} + title={tDanger("resume.title")} + tone="safe" + trailing={ + {tDanger("paused_badge")} + } + /> + ) : ( + ( + { + const mutation = await actions.pause(); + if (mutation.error !== undefined) { + failed(); + + return; + } + onClose(); + toast.success(tDanger("pause.success"), { + description: tDanger("pause.success_desc"), + }); + }} + textSubmit={tDanger("pause.submit")} + title={`${tDanger("pause.title")}?`} + > + {trigger} + + )} + description={tDanger("pause.desc")} + Icon={PauseIcon} + title={tDanger("pause.title")} + /> + )} + ( + { + const mutation = await actions.cancelQueuedEmails(); + if (mutation.error !== undefined) { + failed(); + + return; + } + onClose(); + toast.success(tDanger("cancel_emails.success"), { + description: tDanger("cancel_emails.success_desc", { + count: mutation.data.cancelled, + }), + }); + }} + textSubmit={tDanger("cancel_emails.submit")} + title={`${tDanger("cancel_emails.title")}?`} + > + {trigger} + + )} + description={tDanger("cancel_emails.desc")} + Icon={MailXIcon} + title={tDanger("cancel_emails.title")} + /> + ( + { + const mutation = await actions.markEverythingRead(); + if (mutation.error !== undefined) { + failed(); + + return; + } + onClose(); + toast.success(tDanger("read_all.success"), { + description: tDanger("read_all.success_desc", { + count: mutation.data.items, + }), + }); + }} + textSubmit={tDanger("read_all.submit")} + title={`${tDanger("read_all.title")}?`} + > + {trigger} + + )} + description={tDanger("read_all.desc")} + Icon={CheckCheckIcon} + title={tDanger("read_all.title")} + /> + ( + + )} + description={tDanger("delete_all.desc")} + Icon={Trash2Icon} + title={tDanger("delete_all.title")} + /> +
    + +

    + {t("config_note", { days: workers.retentionDays })} +

    +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/settings-sheet.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/settings-sheet.tsx new file mode 100644 index 000000000..038eafe9d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/settings-sheet.tsx @@ -0,0 +1,66 @@ +import { Settings2Icon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; + +import type { NotificationsSettingsSheetBodyProps } from "./settings-sheet-content"; + +const NotificationsSettingsSheetBody = React.lazy(async () => + import("./settings-sheet-content").then(module => ({ + default: module.NotificationsSettingsSheetBody, + })), +); + +const SheetBodySkeleton = () => ( +
    + {["h-48", "h-44", "h-24", "h-60"].map(height => ( +
    + + +
    + ))} +
    +); + +export const NotificationsSettingsSheet = ( + props: NotificationsSettingsSheetBodyProps, +) => { + const t = useTranslations("admin.system.notifications.settings"); + const [open, setOpen] = React.useState(false); + + return ( + <> + + + + + {t("title")} + {t("desc")} + +
    + }> + + +
    +
    +
    + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/type-policy.test.ts b/packages/vitnode/src/views/admin/views/core/system/notifications/type-policy.test.ts new file mode 100644 index 000000000..d80d6598d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/type-policy.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; + +import type { AdminNotificationType } from "./notifications-query"; + +import { + applyPatch, + choicesOf, + emailPatch, + isSentNowhere, + listPatch, +} from "./type-policy"; + +const type = ( + overrides: Partial = {}, + policy: Partial = {}, +): AdminNotificationType => ({ + category: "content", + defaults: { email: "daily", inApp: true }, + emailAvailable: true, + emailSupported: true, + grouped: false, + id: "blog.article_published", + label: "New article", + mandatory: false, + pluginId: "@vitnode/blog", + version: 1, + ...overrides, + policy: { + allowEmail: true, + allowInApp: true, + allowPush: true, + email: null, + enabled: true, + inApp: null, + memberCanEdit: true, + ...policy, + }, +}); + +describe("choicesOf", () => { + it("reads the installation policy, falling back to the type's defaults", () => { + expect(choicesOf(type())).toEqual({ + email: "default_on", + list: "default_on", + memberCanEdit: true, + push: "available", + }); + expect(choicesOf(type({}, { email: "none", inApp: false }))).toMatchObject({ + email: "default_off", + list: "default_off", + }); + }); + + it("shows a type switched off by the old on/off flag as disabled everywhere", () => { + const choices = choicesOf(type({}, { enabled: false })); + + expect(choices).toMatchObject({ + email: "disabled", + list: "disabled", + push: "disabled", + }); + expect(isSentNowhere(choices)).toBe(true); + }); + + it("keeps mandatory types in the list and locked for members", () => { + expect( + choicesOf(type({ mandatory: true }, { allowInApp: false })), + ).toMatchObject({ + list: "default_on", + memberCanEdit: false, + }); + }); + + it("has no email choice for a type without an email version", () => { + expect(choicesOf(type({ emailSupported: false })).email).toBeNull(); + }); +}); + +describe("patches", () => { + it("turns a list choice into the policy flags the API stores", () => { + expect(listPatch("disabled")).toEqual({ allowInApp: false, enabled: true }); + expect(listPatch("default_off")).toEqual({ + allowInApp: true, + enabled: true, + inApp: false, + }); + }); + + it("keeps the email frequency when email goes back to enabled by default", () => { + expect( + emailPatch("default_on", type({}, { email: "weekly" })), + ).toMatchObject({ + email: "weekly", + }); + expect(emailPatch("default_on", type({}, { email: "none" }))).toMatchObject( + { + email: "daily", + }, + ); + expect( + emailPatch( + "default_on", + type({ defaults: { email: "none", inApp: true } }, { email: "none" }), + ), + ).toMatchObject({ email: "immediate" }); + }); + + it("applies a patch without dropping the rest of the policy", () => { + const next = applyPatch( + type({}, { allowPush: false }), + listPatch("default_off"), + ); + + expect(next.policy).toMatchObject({ allowPush: false, inApp: false }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/type-policy.ts b/packages/vitnode/src/views/admin/views/core/system/notifications/type-policy.ts new file mode 100644 index 000000000..adf4f43b9 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/type-policy.ts @@ -0,0 +1,93 @@ +import type { NotificationEmailMode } from "@/lib/notifications/types"; + +import type { NotificationTypePolicyPatch } from "./notifications-mutations"; +import type { AdminNotificationType } from "./notifications-query"; + +export const LIST_CHOICES = ["default_on", "default_off", "disabled"] as const; +export const PUSH_CHOICES = ["available", "disabled"] as const; +export const EMAIL_CHOICES = ["default_on", "default_off", "disabled"] as const; + +export type ListChoice = (typeof LIST_CHOICES)[number]; +export type PushChoice = (typeof PUSH_CHOICES)[number]; +export type EmailChoice = (typeof EMAIL_CHOICES)[number]; + +export interface TypeChoices { + email: EmailChoice | null; + list: ListChoice; + memberCanEdit: boolean; + push: PushChoice; +} + +const emailModeOf = (type: AdminNotificationType): NotificationEmailMode => + type.policy.email ?? type.defaults.email; + +export const choicesOf = (type: AdminNotificationType): TypeChoices => { + const off = !type.policy.enabled && !type.mandatory; + + return { + email: !type.emailSupported + ? null + : off || !type.policy.allowEmail + ? "disabled" + : emailModeOf(type) === "none" + ? "default_off" + : "default_on", + list: type.mandatory + ? "default_on" + : off || !type.policy.allowInApp + ? "disabled" + : (type.policy.inApp ?? type.defaults.inApp) + ? "default_on" + : "default_off", + memberCanEdit: !type.mandatory && type.policy.memberCanEdit, + push: off || !type.policy.allowPush ? "disabled" : "available", + }; +}; + +export const isSentNowhere = (choices: TypeChoices): boolean => + choices.list === "disabled" && + choices.push === "disabled" && + (choices.email === null || choices.email === "disabled"); + +export const listPatch = (choice: ListChoice): NotificationTypePolicyPatch => + choice === "disabled" + ? { allowInApp: false, enabled: true } + : { allowInApp: true, enabled: true, inApp: choice === "default_on" }; + +export const pushPatch = (choice: PushChoice): NotificationTypePolicyPatch => ({ + allowPush: choice === "available", + enabled: true, +}); + +export const emailPatch = ( + choice: EmailChoice, + type: AdminNotificationType, +): NotificationTypePolicyPatch => { + if (choice === "disabled") return { allowEmail: false, enabled: true }; + if (choice === "default_off") { + return { allowEmail: true, email: "none", enabled: true }; + } + + const current = emailModeOf(type); + const frequency = + current !== "none" + ? current + : type.defaults.email !== "none" + ? type.defaults.email + : "immediate"; + + return { allowEmail: true, email: frequency, enabled: true }; +}; + +export const applyPatch = ( + type: AdminNotificationType, + patch: NotificationTypePolicyPatch, +): AdminNotificationType => ({ + ...type, + policy: { + ...type.policy, + ...Object.fromEntries( + Object.entries(patch).filter(([, value]) => value !== undefined), + ), + }, +}); diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/types-content.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/types-content.tsx new file mode 100644 index 000000000..cd17ce122 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/types-content.tsx @@ -0,0 +1,496 @@ +import { cn } from "cn"; +import { + BellOffIcon, + ChevronDownIcon, + ListIcon, + LockIcon, + type LucideIcon, + MailIcon, + SmartphoneIcon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { RevealPanel } from "@/components/reveal-panel"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Switch } from "@/components/ui/switch"; + +import type { + NotificationsAdminActions, + NotificationTypePolicyPatch, +} from "./notifications-mutations"; +import type { AdminNotificationType } from "./notifications-query"; +import type { TypeChoices } from "./type-policy"; + +import { ResetMembersButton } from "./reset-members"; +import { + applyPatch, + choicesOf, + EMAIL_CHOICES, + emailPatch, + isSentNowhere, + LIST_CHOICES, + listPatch, + PUSH_CHOICES, + pushPatch, +} from "./type-policy"; + +const SummaryChip = ({ + Icon, + label, + muted, + value, +}: { + Icon: LucideIcon; + label: string; + muted: boolean; + value: string; +}) => ( +
  • + + {label}: + {value} +
  • +); + +const ChoiceGroup = ({ + disabled, + Icon, + legend, + note, + onChange, + options, + value, +}: { + disabled: boolean; + Icon: LucideIcon; + legend: string; + note?: string; + onChange: (value: V) => void; + options: readonly { hint: string; label: string; value: V }[]; + value: V; +}) => { + return ( +
    + + + {legend} + + { + const picked = options.find(option => option.value === next); + if (picked) onChange(picked.value); + }} + value={value} + > + {options.map(option => ( + + ))} + + {note ? ( +

    + {note} +

    + ) : null} +
    + ); +}; + +const TypeBadges = ({ + choices, + missingEmailTemplate, + sentNowhere, + type, +}: { + choices: TypeChoices; + missingEmailTemplate: boolean; + sentNowhere: boolean; + type: AdminNotificationType; +}) => { + const t = useTranslations("admin.system.notifications.types"); + const lockBadge = type.mandatory + ? t("badges.mandatory") + : choices.memberCanEdit + ? null + : t("badges.locked"); + + if (!lockBadge && !sentNowhere && !type.grouped && !missingEmailTemplate) { + return null; + } + + return ( + + {lockBadge ? ( + + + {lockBadge} + + ) : null} + {sentNowhere ? ( + {t("badges.nowhere")} + ) : null} + {type.grouped ? ( + {t("badges.grouped")} + ) : null} + {missingEmailTemplate ? ( + {t("badges.no_template")} + ) : null} + + ); +}; + +const TypeSummaryChips = ({ + choices, + type, +}: { + choices: TypeChoices; + type: AdminNotificationType; +}) => { + const t = useTranslations("admin.system.notifications.types"); + const short = { + available: t("summary.available"), + default_off: t("summary.off"), + default_on: t("summary.on"), + disabled: t("summary.disabled"), + }; + + return ( +
      + + + +
    + ); +}; + +const TypeItem = ({ + canEdit, + isOpen, + onToggle, + onUpdate, + type: initial, +}: { + canEdit: boolean; + isOpen: boolean; + onToggle: () => void; + onUpdate: NotificationsAdminActions["updateTypePolicy"]; + type: AdminNotificationType; +}) => { + const t = useTranslations("admin.system.notifications.types"); + const tError = useTranslations("core.global.errors"); + const panelId = React.useId(); + const memberId = React.useId(); + const [, startTransition] = React.useTransition(); + const [type, applyOptimistic] = React.useOptimistic(initial, applyPatch); + const choices = choicesOf(type); + const sentNowhere = isSentNowhere(choices); + const missingEmailTemplate = + type.emailSupported && !type.emailAvailable && choices.email !== "disabled"; + const locked = !canEdit; + + const save = (patch: NotificationTypePolicyPatch) => { + startTransition(async () => { + applyOptimistic(patch); + const mutation = await onUpdate(type.id, patch); + + if (mutation.error !== undefined) { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + + return; + } + + toast.success(t("success"), { + description: t("success_desc", { type: type.label }), + }); + }); + }; + + const listOptions = LIST_CHOICES.map(value => ({ + hint: t(`list.${value}.hint`), + label: t(`list.${value}.label`), + value, + })); + const pushOptions = PUSH_CHOICES.map(value => ({ + hint: t(`push.${value}.hint`), + label: t(`push.${value}.label`), + value, + })); + const emailOptions = EMAIL_CHOICES.map(value => ({ + hint: t(`email.${value}.hint`), + label: t(`email.${value}.label`), + value, + })); + + return ( +
  • +
    +
    +

    + +

    + + {type.id} + + +
    +
    + + +
    +
    + + +
    +
    + + { + save({ memberCanEdit }); + }} + /> +
    + +
    + { + save(listPatch(choice)); + }} + options={listOptions} + value={choices.list} + /> + { + save(pushPatch(choice)); + }} + options={pushOptions} + value={choices.push} + /> + {choices.email === null ? ( +
    +

    + + {t("email.legend")} +

    +

    + {t("email.unsupported")} +

    +
    + ) : ( + { + save(emailPatch(choice, type)); + }} + options={emailOptions} + value={choices.email} + /> + )} +
    +
    +
    +
  • + ); +}; + +export const NotificationsTypesSection = ({ + actions, + canEdit, + canManage, + customizedMembers, + types, +}: { + actions: NotificationsAdminActions; + canEdit: boolean; + canManage: boolean; + customizedMembers: number; + types: AdminNotificationType[]; +}) => { + const t = useTranslations("admin.system.notifications.types"); + const [openId, setOpenId] = React.useState(null); + const groups = [...new Set(types.map(type => type.pluginId))].map( + pluginId => ({ + pluginId, + types: types.filter(type => type.pluginId === pluginId), + }), + ); + + return ( +
    +
    +
    +

    + {t("title")} +

    +

    + {canEdit ? t("desc") : `${t("desc")} ${t("read_only")}`} +

    +
    + {canManage ? ( + + ) : null} +
    + + {types.length === 0 ? ( + + + + + + + {t("empty.title")} + {t("empty.desc")} + + + + ) : ( + groups.map(group => ( +
    +

    + {group.pluginId} +

    + +
      + {group.types.map(type => ( + { + setOpenId(current => + current === type.id ? null : type.id, + ); + }} + onUpdate={actions.updateTypePolicy} + type={type} + /> + ))} +
    +
    +
    + )) + )} +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/edit-sheet-content.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/edit-sheet-content.tsx new file mode 100644 index 000000000..a08059bf8 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/edit-sheet-content.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +import { + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Spinner } from "@/components/ui/spinner"; + +export const EditSheetContent = ({ + children, + description, + title, +}: { + children: React.ReactNode; + description: React.ReactNode; + title: React.ReactNode; +}) => ( + + + {title} + {description} + +
    + + +
    + } + > + {children} + +
    + +); diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/name-code-form.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/name-code-form.tsx new file mode 100644 index 000000000..3039f92b8 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/name-code-form.tsx @@ -0,0 +1,98 @@ +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 { AutoFormInput } from "@/components/form/fields/input"; +import { useDialog } from "@/components/ui/dialog"; +import { setFormFieldError } from "@/components/ui/form"; + +import type { UpdateAdminUser } from "./user-fields-content"; + +export const NameCodeForm = ({ + id, + nameCode, + onUpdate, +}: { + id: number; + nameCode: string; + onUpdate: UpdateAdminUser; +}) => { + const t = useTranslations("admin.user.show"); + const tError = useTranslations("core.global.errors"); + const { setIsDirty, setOpen } = useDialog(); + + const formSchema = z.object({ + currentNameCode: z + .string({ message: tError("field_required") }) + .refine(value => value === nameCode, t("nameCodeConfirmMismatch")) + .default(""), + newNameCode: z + .string({ message: tError("field_required") }) + .min(3, tError("field_min_length", { min: 3 })) + .max(255) + .regex(/^[a-zA-Z0-9-]+$/, t("nameCodeInvalid")) + .refine(value => value !== nameCode, t("nameCodeSame")) + .default(""), + }); + + const onSubmit: AutoFormOnSubmit = async ( + values, + form, + ) => { + const result = await onUpdate(id, { nameCode: values.newNameCode }); + + if ("data" in result) { + setIsDirty?.(false); + setOpen?.(false); + toast.success(t("updateSuccess")); + + return; + } + + if (result.error.status === 409) { + setFormFieldError(form, "newNameCode", t("nameCodeExists")); + + return; + } + + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + }; + + return ( + ( + {chunks}, + nameCode: () => {nameCode}, + })} + {...props} + /> + ), + id: "currentNameCode", + }, + { + component: props => ( + + ), + id: "newNameCode", + }, + ]} + formSchema={formSchema} + mode="all" + onSubmit={onSubmit} + submitButtonProps={{ + children: t("saveNameCode"), + variant: "destructive", + }} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/use-failure-toast.ts b/packages/vitnode/src/views/admin/views/core/users/detail/use-failure-toast.ts new file mode 100644 index 000000000..3f75f574c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/use-failure-toast.ts @@ -0,0 +1,12 @@ +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +export const useFailureToast = () => { + const tError = useTranslations("core.global.errors"); + + return () => { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + }; +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-account-mutations.ts b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-mutations.ts new file mode 100644 index 000000000..96aa3bcd5 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-mutations.ts @@ -0,0 +1,180 @@ +import type { SsoProfileField } from "@/lib/sso-profile"; +import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; + +import { CONFIG_PLUGIN } from "@/config"; +import { fetcherClient } from "@/lib/fetcher-client"; +import { + type AdminMutationResult, + runAdminApiMutation, +} from "@/views/admin/views/core/shared/admin-mutation"; + +import type { AdminUserPasskey } from "./user-account-query"; + +const OPTIONS = { credentials: "include" } as const; + +const ok = () => true as const; + +export const setAdminUserPassword = async ( + id: number, + password: string, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: { password }, params: { id: String(id) } }, + method: "put", + module: "admin/users", + options: OPTIONS, + path: "/{id}/password", + }), + }); + +export const revokeAdminUserDevice = async ( + id: number, + publicId: string, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { + params: { id: String(id), publicId: encodeURIComponent(publicId) }, + }, + method: "delete", + module: "admin/users", + options: OPTIONS, + path: "/{id}/devices/{publicId}", + }), + }); + +export const revokeAdminUserDevices = async ( + id: number, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { id: String(id) } }, + method: "delete", + module: "admin/users", + options: OPTIONS, + path: "/{id}/devices", + }), + }); + +export const renameAdminUserPasskey = async ( + id: number, + passkeyId: number, + name: string, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: async response => (await response.json()) as AdminUserPasskey, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { + body: { name }, + params: { id: String(id), passkeyId: String(passkeyId) }, + }, + method: "patch", + module: "admin/users", + options: OPTIONS, + path: "/{id}/passkeys/{passkeyId}", + }), + }); + +export const deleteAdminUserPasskey = async ( + id: number, + passkeyId: number, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { id: String(id), passkeyId: String(passkeyId) } }, + method: "delete", + module: "admin/users", + options: OPTIONS, + path: "/{id}/passkeys/{passkeyId}", + }), + }); + +export const disconnectAdminUserSso = async ( + id: number, + providerId: string, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { + params: { + id: String(id), + providerId: encodeURIComponent(providerId), + }, + }, + method: "delete", + module: "admin/users", + options: OPTIONS, + path: "/{id}/sso/{providerId}", + }), + }); + +export interface AdminUserSsoPreferencesInput { + sources: Partial>; + sync: Record; +} + +export const updateAdminUserSsoPreferences = async ( + id: number, + body: AdminUserSsoPreferencesInput, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body, params: { id: String(id) } }, + method: "put", + module: "admin/users", + options: OPTIONS, + path: "/{id}/sso/preferences", + }), + }); + +export type AdminUserNotificationPatch = Partial< + NotificationPreferenceTypeView["value"] +>; + +export const updateAdminUserNotificationPreferences = async ( + id: number, + types: Record, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: async response => + ((await response.json()) as { types: NotificationPreferenceTypeView[] }) + .types, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: { types }, params: { id: String(id) } }, + method: "put", + module: "admin/users", + options: OPTIONS, + path: "/{id}/notification-preferences", + }), + }); diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-account-panels.test.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-panels.test.tsx new file mode 100644 index 000000000..7dc4f7733 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-panels.test.tsx @@ -0,0 +1,364 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SsoConnectionsApi } from "@/views/auth/settings/sso/sso-connections-query"; +import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; + +import { adminUserFixture } from "@/tests/admin-user"; + +import type { AdminUserDevice } from "./user-account-query"; + +import { + adminUserDevicesQueryKey, + adminUserNotificationsQueryKey, + adminUserSsoQueryKey, +} from "./user-account-query"; +import { UserConnectedAccountsCard } from "./user-connected-accounts"; +import { UserDevicesCard } from "./user-devices"; +import { UserNotificationsPanel } from "./user-notifications"; +import { UserSecurityPanel } from "./user-security"; + +const ADMIN_ID = 1; +const USER_ID = 7; +const KEY = { adminUserId: ADMIN_ID, userId: USER_ID }; + +const user = adminUserFixture({ id: USER_ID, name: "Target" }); + +const device = (publicId: string, browser: string): AdminUserDevice => ({ + browser, + deviceType: "desktop", + expiresAt: "2026-12-01T00:00:00.000Z", + ipAddress: "10.0.0.1", + lastSeen: "2026-10-01T00:00:00.000Z", + os: "macOS", + publicId, + sessionKinds: ["user"], +}); + +const sso = ( + hasPassword: boolean, + signIn: Partial = {}, +): SsoConnectionsApi => ({ + providers: [ + { + available: true, + connection: { + accountLabel: "octocat", + connectedAt: "2026-03-01T00:00:00.000Z", + email: null, + syncOnSignIn: false, + }, + icon: null, + id: "github", + name: "GitHub", + profileFields: ["avatar"], + }, + ], + signIn: { + hasPassword, + passkeys: 0, + passkeysEnabled: true, + passwordEnabled: true, + ...signIn, + }, + sources: { avatar: null, firstName: null, lastName: null }, +}); + +const notificationType: NotificationPreferenceTypeView = { + category: "account", + categoryLabel: "Account", + defaultEmail: "immediate", + description: null, + emailModes: ["none", "immediate", "daily", "weekly"], + id: "core.example", + inAppAvailable: true, + label: "Example type", + locked: false, + mandatory: false, + pluginId: "@vitnode/core", + pushAvailable: false, + value: { email: "immediate", inApp: true, push: false }, +}; + +const json = (body: unknown) => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status: 200, + }); + +const mount = (ui: React.ReactNode, seed: (client: QueryClient) => void) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + seed(queryClient); + + render( + + {ui} + , + ); +}; + +const fetchMock = vi.fn(); + +const requestOf = (index: number) => { + const [input, init] = fetchMock.mock.calls[index] ?? []; + + return { + body: + typeof init?.body === "string" + ? (JSON.parse(init.body) as unknown) + : undefined, + method: init?.method, + url: + input instanceof Request + ? input.url + : input instanceof URL + ? input.href + : (input ?? ""), + }; +}; + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + addEventListener: vi.fn(), + matches: false, + removeEventListener: vi.fn(), + })), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("UserDevicesCard", () => { + const devices = [ + device("a1", "Chrome"), + device("b2", "Safari"), + device("c3", "Firefox"), + device("d4", "Edge"), + device("e5", "Opera"), + ]; + + it("lists the first three devices until all are revealed", async () => { + mount( + , + client => { + client.setQueryData(adminUserDevicesQueryKey(KEY), devices); + }, + ); + + expect( + await screen.findAllByText("admin.user.show.devices.deviceName"), + ).toHaveLength(3); + + fireEvent.click( + screen.getByRole("button", { name: "admin.user.show.devices.showAll" }), + ); + + expect( + screen.getAllByText("admin.user.show.devices.deviceName"), + ).toHaveLength(5); + }); + + it("signs one device out after the confirmation", async () => { + fetchMock.mockImplementation( + async (_input, init) => + await Promise.resolve( + init?.method === "DELETE" + ? json({ ok: true }) + : json({ devices: devices.slice(1) }), + ), + ); + mount( + , + client => { + client.setQueryData(adminUserDevicesQueryKey(KEY), devices); + }, + ); + + const [first] = await screen.findAllByRole("button", { + name: "admin.user.show.devices.signOutLabel", + }); + if (!first) throw new Error("No sign-out button"); + fireEvent.click(first); + fireEvent.click( + await screen.findByText("admin.user.show.devices.signOutSubmit"), + ); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + const request = requestOf(0); + expect(request.method).toBe("DELETE"); + expect(request.url).toContain(`/${USER_ID}/devices/a1`); + }); + + it("has no sign-out controls without edit permission", async () => { + mount( + , + client => { + client.setQueryData(adminUserDevicesQueryKey(KEY), devices); + }, + ); + + await screen.findAllByText("admin.user.show.devices.deviceName"); + expect(screen.queryByText("admin.user.show.devices.signOutAll")).toBeNull(); + expect( + screen.queryByRole("button", { + name: "admin.user.show.devices.signOutLabel", + }), + ).toBeNull(); + }); +}); + +describe("UserConnectedAccountsCard", () => { + const disconnect = async () => + screen.findByRole("button", { name: "admin.user.show.sso.disconnect" }); + + it("blocks disconnecting the only way to sign in", async () => { + mount( + , + client => { + client.setQueryData(adminUserSsoQueryKey(KEY), sso(false)); + }, + ); + + expect((await disconnect()).hasAttribute("disabled")).toBe(true); + }); + + it("disconnects an account when the member still has a password", async () => { + fetchMock.mockImplementation( + async (_input, init) => + await Promise.resolve( + init?.method === "DELETE" ? json({ ok: true }) : json(sso(true)), + ), + ); + mount( + , + client => { + client.setQueryData(adminUserSsoQueryKey(KEY), sso(true)); + }, + ); + + const button = await disconnect(); + expect(button.hasAttribute("disabled")).toBe(false); + fireEvent.click(button); + fireEvent.click( + await screen.findByText("admin.user.show.sso.disconnectSubmit"), + ); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + const request = requestOf(0); + expect(request.method).toBe("DELETE"); + expect(request.url).toContain(`/${USER_ID}/sso/github`); + }); +}); + +describe("UserSecurityPanel", () => { + const panel = ( + + ); + + it("reports a failed sign-in lookup instead of loading passkeys forever", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 500 })); + mount(panel, () => {}); + + expect( + await screen.findByText("admin.user.show.security.passkeysLoadError"), + ).not.toBeNull(); + expect( + screen.getByText("admin.user.show.security.passwordLoadError"), + ).not.toBeNull(); + expect( + screen.queryByRole("button", { + name: "admin.user.show.security.passwordAdd", + }), + ).toBeNull(); + }); + + it("offers no password reset while password sign-in is turned off", async () => { + mount(panel, client => { + client.setQueryData( + adminUserSsoQueryKey(KEY), + sso(false, { passkeysEnabled: false, passwordEnabled: false }), + ); + }); + + expect( + await screen.findByText("admin.user.show.security.passwordDisabled"), + ).not.toBeNull(); + expect( + screen.queryByRole("button", { + name: "admin.user.show.security.passwordAdd", + }), + ).toBeNull(); + }); + + it("offers a password reset while password sign-in is on", async () => { + mount(panel, client => { + client.setQueryData( + adminUserSsoQueryKey(KEY), + sso(false, { passkeysEnabled: false }), + ); + }); + + expect( + await screen.findByRole("button", { + name: "admin.user.show.security.passwordAdd", + }), + ).not.toBeNull(); + }); +}); + +describe("UserNotificationsPanel", () => { + it("saves a channel change for the member", async () => { + fetchMock.mockImplementation( + async () => + await Promise.resolve( + json({ + types: [ + { + ...notificationType, + value: { ...notificationType.value, inApp: false }, + }, + ], + }), + ), + ); + mount( + , + client => { + client.setQueryData(adminUserNotificationsQueryKey(KEY), [ + notificationType, + ]); + }, + ); + + fireEvent.click( + await screen.findByRole("button", { name: "Example type" }), + ); + const [inApp] = screen.getAllByRole("switch"); + if (!inApp) throw new Error("No in-app switch"); + fireEvent.click(inApp); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + const request = requestOf(0); + expect(request.method).toBe("PUT"); + expect(request.url).toContain(`/${USER_ID}/notification-preferences`); + expect(request.body).toEqual({ + types: { "core.example": { inApp: false } }, + }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-account-query.ts b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-query.ts new file mode 100644 index 000000000..db9c123b9 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-query.ts @@ -0,0 +1,181 @@ +import { queryOptions } from "@tanstack/react-query"; + +import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; +import type { SsoConnectionsApi } from "@/views/auth/settings/sso/sso-connections-query"; +import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; + +import { CONFIG_PLUGIN } from "@/config"; +import { RECORD_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; +import { AdminRequestError } from "@/views/admin/admin-request"; + +import { adminUserQueryKey } from "./user-query"; + +export interface AdminUserDevice { + browser: null | string; + deviceType: "desktop" | "mobile" | "tablet"; + expiresAt: Date | string; + ipAddress: string; + lastSeen: Date | string; + os: null | string; + publicId: string; + sessionKinds: ("admin" | "user")[]; +} + +export interface AdminUserPasskey { + backedUp: boolean; + createdAt: Date | string; + deviceType: "multiDevice" | "singleDevice"; + id: number; + lastUsedAt: Date | null | string; + name: string; + transports: string[]; +} + +interface AdminUserAccountKey { + adminUserId: AdminIdentity; + userId: number; +} + +const accountKey = ( + { adminUserId, userId }: AdminUserAccountKey, + part: string, +) => [...adminUserQueryKey({ adminUserId, id: String(userId) }), part] as const; + +export const adminUserDevicesQueryKey = (key: AdminUserAccountKey) => + accountKey(key, "devices"); + +export const adminUserPasskeysQueryKey = (key: AdminUserAccountKey) => + accountKey(key, "passkeys"); + +export const adminUserSsoQueryKey = (key: AdminUserAccountKey) => + accountKey(key, "sso"); + +export const adminUserNotificationsQueryKey = (key: AdminUserAccountKey) => + accountKey(key, "notifications"); + +const params = (userId: number) => ({ id: String(userId) }); + +export const fetchAdminUserDevices = async ( + userId: number, +): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: params(userId) }, + method: "get", + module: "admin/users", + path: "/{id}/devices", + }); + if (!response.ok) { + throw new AdminRequestError( + response.status, + "a user's devices", + `id=${userId}`, + ); + } + + return (await response.json()).devices; +}; + +export const fetchAdminUserPasskeys = async ( + userId: number, +): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: params(userId) }, + method: "get", + module: "admin/users", + path: "/{id}/passkeys", + }); + if (!response.ok) { + throw new AdminRequestError( + response.status, + "a user's passkeys", + `id=${userId}`, + ); + } + + return (await response.json()).items; +}; + +export const fetchAdminUserSso = async ( + userId: number, +): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: params(userId) }, + method: "get", + module: "admin/users", + path: "/{id}/sso", + }); + if (!response.ok) { + throw new AdminRequestError( + response.status, + "a user's connected accounts", + `id=${userId}`, + ); + } + + return await response.json(); +}; + +export const fetchAdminUserNotificationPreferences = async ( + userId: number, +): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: params(userId) }, + method: "get", + module: "admin/users", + path: "/{id}/notification-preferences", + }); + if (!response.ok) { + throw new AdminRequestError( + response.status, + "a user's notification preferences", + `id=${userId}`, + ); + } + + return (await response.json()).types; +}; + +export const adminUserDevicesQueryOptions = (key: AdminUserAccountKey) => + queryOptions({ + queryFn: async () => await fetchAdminUserDevices(key.userId), + queryKey: adminUserDevicesQueryKey(key), + staleTime: RECORD_STALE_TIME, + }); + +export const adminUserPasskeysQueryOptions = (key: AdminUserAccountKey) => + queryOptions({ + queryFn: async () => await fetchAdminUserPasskeys(key.userId), + queryKey: adminUserPasskeysQueryKey(key), + staleTime: RECORD_STALE_TIME, + }); + +export const adminUserSsoQueryOptions = (key: AdminUserAccountKey) => + queryOptions({ + queryFn: async () => await fetchAdminUserSso(key.userId), + queryKey: adminUserSsoQueryKey(key), + staleTime: RECORD_STALE_TIME, + }); + +export const adminUserNotificationsQueryOptions = (key: AdminUserAccountKey) => + queryOptions({ + queryFn: async () => + await fetchAdminUserNotificationPreferences(key.userId), + queryKey: adminUserNotificationsQueryKey(key), + staleTime: RECORD_STALE_TIME, + }); + +export const countSignInMethods = ({ + passkeys, + sso, +}: { + passkeys: number; + sso: SsoConnectionsApi | undefined; +}): number => + (sso?.signIn.hasPassword ? 1 : 0) + + passkeys + + (sso?.providers.filter(provider => provider.connection).length ?? 0); diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-connected-accounts.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-connected-accounts.tsx new file mode 100644 index 000000000..dcbb6b184 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-connected-accounts.tsx @@ -0,0 +1,410 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + KeyRoundIcon, + RefreshCwIcon, + Settings2Icon, + UnplugIcon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; +import type { SsoConnectionsApi } from "@/views/auth/settings/sso/sso-connections-query"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardHeader, +} from "@/components/ui/card"; +import { Dialog, DialogTrigger } from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipWithContent, +} from "@/components/ui/tooltip"; +import { SSO_PROFILE_FIELDS } from "@/lib/sso-profile"; +import { + SETTINGS_ROW, + SettingsGroup, +} from "@/views/auth/settings/settings-group"; +import { SsoFieldsGroup } from "@/views/auth/settings/sso/sso-fields-group"; +import { SsoProviderMark } from "@/views/auth/settings/sso/sso-provider-badge"; +import { normalizeSSOProviders } from "@/views/auth/sso/providers"; + +import type { AdminUserDetail } from "./user-query"; + +import { EditSheetContent } from "./edit-sheet-content"; +import { useFailureToast } from "./use-failure-toast"; +import { + disconnectAdminUserSso, + updateAdminUserSsoPreferences, +} from "./user-account-mutations"; +import { + adminUserSsoQueryOptions, + countSignInMethods, +} from "./user-account-query"; +import { DetailCardTitle } from "./user-profile-cards"; +import { adminUserQueryKey } from "./user-query"; + +type Provider = SsoConnectionsApi["providers"][number]; + +type ConnectedProvider = Provider & { + connection: NonNullable; +}; + +const connectedProviders = (data: SsoConnectionsApi): ConnectedProvider[] => + data.providers.filter( + (provider): provider is ConnectedProvider => provider.connection !== null, + ); + +const sourcedFields = (data: SsoConnectionsApi, providerId: string) => + SSO_PROFILE_FIELDS.filter(field => data.sources[field] === providerId); + +const SyncSwitch = ({ + onToggle, + provider, +}: { + onToggle: (checked: boolean) => Promise; + provider: ConnectedProvider; +}) => { + const tSso = useTranslations("core.auth.settings.sso"); + const [isSaving, setIsSaving] = React.useState(false); + + return ( + { + setIsSaving(true); + try { + await onToggle(checked); + } finally { + setIsSaving(false); + } + }} + /> + ); +}; + +const EditSsoDialog = ({ + data, + onSaved, + user, +}: { + data: SsoConnectionsApi; + onSaved: () => Promise; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.sso"); + const tSso = useTranslations("core.auth.settings.sso"); + const showFailure = useFailureToast(); + const connected = connectedProviders(data); + const views = normalizeSSOProviders( + data.providers.map(({ icon, id, name }) => ({ icon, id, name })), + ); + + return ( + + + + } + > + + + + +
    + { + const result = await updateAdminUserSsoPreferences(user.id, body); + if ("error" in result) { + return { + failure: + result.error.status === 400 + ? "invalid_source" + : "server_error", + ok: false, + }; + } + await onSaved(); + + return { ok: true }; + }} + profile={{ + avatarUrl: user.avatarUrl, + firstName: user.firstName, + lastName: user.lastName, + }} + /> + + {connected.map(provider => { + const fields = sourcedFields(data, provider.id); + + return ( +
  • + view.id === provider.id) ?? { + id: provider.id, + name: provider.name, + } + } + /> +
    + + {provider.name} + + + {fields.length > 0 + ? fields + .map(field => tSso(`fields.${field}`)) + .join(", ") + : t("syncNoFields")} + +
    + { + const result = await updateAdminUserSsoPreferences( + user.id, + { sources: {}, sync: { [provider.id]: checked } }, + ); + if ("error" in result) { + showFailure(); + + return; + } + await onSaved(); + toast.success( + checked + ? t("syncOn", { provider: provider.name }) + : t("syncOff", { provider: provider.name }), + ); + }} + provider={provider} + /> +
  • + ); + })} +
    +
    +
    +
    + ); +}; + +const DisconnectButton = ({ + disabledReason, + onDisconnect, + provider, + userName, +}: { + disabledReason?: string; + onDisconnect: () => Promise; + provider: Provider; + userName: string; +}) => { + const t = useTranslations("admin.user.show.sso"); + const label = t("disconnect", { provider: provider.name }); + + if (disabledReason) { + return ( + + + + + + ); + } + + return ( + + } + onSubmit={async ({ onClose }) => { + await onDisconnect(); + onClose(); + }} + textSubmit={t("disconnectSubmit")} + title={t("disconnectTitle", { provider: provider.name })} + > + } + > + + + + {label} + + ); +}; + +export const UserConnectedAccountsCard = ({ + adminUserId, + canEdit, + user, +}: { + adminUserId: AdminIdentity; + canEdit: boolean; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.sso"); + const tSso = useTranslations("core.auth.settings.sso"); + const showFailure = useFailureToast(); + const queryClient = useQueryClient(); + const key = { adminUserId, userId: user.id }; + const { data, isError, isPending } = useQuery(adminUserSsoQueryOptions(key)); + + const refresh = async () => { + await queryClient.invalidateQueries({ + queryKey: adminUserQueryKey({ adminUserId, id: String(user.id) }), + }); + }; + + const connected = data ? connectedProviders(data) : []; + const views = normalizeSSOProviders( + (data?.providers ?? []).map(({ icon, id, name }) => ({ icon, id, name })), + ); + const isLastMethod = + countSignInMethods({ passkeys: data?.signIn.passkeys ?? 0, sso: data }) <= + 1; + + return ( + + + {t("title")} + {canEdit && data && connected.length > 0 && ( + + + + )} + + + {isPending ? ( +
    + + +
    + ) : isError ? ( +

    + {t("loadError")} +

    + ) : connected.length === 0 ? ( +

    + {t("empty")} +

    + ) : ( +
      + {connected.map(provider => { + const fields = sourcedFields(data, provider.id); + const { connection } = provider; + + return ( +
    • + view.id === provider.id) ?? { + id: provider.id, + name: provider.name, + } + } + /> +
      + + + {connection.accountLabel ?? provider.name} + + + {provider.name} + {connection.email ? ` · ${connection.email}` : ""} + + + {t("connected")}{" "} + + + + {(fields.length > 0 || connection.syncOnSignIn) && ( + + {fields.length > 0 && ( + + {t("sourceBadge", { + fields: fields + .map(field => tSso(`fields.${field}`)) + .join(", "), + })} + + )} + {connection.syncOnSignIn && ( + + + {t("syncsBadge")} + + )} + + )} +
      + {canEdit && ( + { + const result = await disconnectAdminUserSso( + user.id, + provider.id, + ); + if ("error" in result) { + if (result.error.status === 409) { + toast.error(t("lastMethod"), { + description: t("lastMethodDesc"), + }); + } else { + showFailure(); + } + + return; + } + await refresh(); + toast.success( + t("disconnected", { provider: provider.name }), + { + description: t("disconnectedDesc", { + name: user.name, + }), + }, + ); + }} + provider={provider} + userName={user.name} + /> + )} +
    • + ); + })} +
    + )} +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.test.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.test.tsx index 1b0b9738d..7554a5b73 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.test.tsx @@ -4,12 +4,14 @@ import { createRouter, RouterProvider, } from "@tanstack/react-router"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { IntlProvider } from "use-intl"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { StaffPermissionSet } from "@/api/lib/permission-staff"; +import { adminUserFixture } from "@/tests/admin-user"; + import type { AdminUserDetail } from "./user-query"; import { UserDetailContent } from "./user-detail-content"; @@ -24,31 +26,6 @@ const permissionSet = (...permissions: string[]): StaffPermissionSet => ({ root: false, }); -const MEMBER_ROLE = { color: null, id: 3, name: [] }; - -const userFixture = (isStaff: boolean): AdminUserDetail => ({ - avatarColor: "#123456", - avatarUrl: null, - birthday: null, - coverUrl: null, - createdAt: "2026-01-01T00:00:00.000Z", - email: "moderator@example.com", - emailVerified: true, - id: 7, - imagePolicy: { - avatar: { allowed: true, maxBytes: 1_000_000 }, - cover: { allowed: true, maxBytes: 1_000_000 }, - }, - isStaff, - language: "en", - name: "Moderator", - nameCode: "moderator", - newsletter: false, - role: MEMBER_ROLE, - roleId: MEMBER_ROLE.id, - secondaryRoles: [], -}); - const mount = async ({ permissions, user, @@ -60,12 +37,17 @@ const mount = async ({ component: () => ( connected accounts slot

    } + devices={

    devices slot

    } + notifications={

    notifications slot

    } onRemoveImage={vi.fn()} onUpdate={vi.fn()} onUpdateRoles={vi.fn()} onUploadImage={vi.fn()} + onVerifyEmail={vi.fn()} searchRoles={vi.fn()} - timeline={null} + security={

    security slot

    } + timeline={

    timeline slot

    } user={user} /> ), @@ -88,6 +70,8 @@ const editControls = () => [ screen.queryByRole("button", { name: "admin.user.show.editName" }), screen.queryByRole("button", { name: "admin.user.show.editEmail" }), screen.queryByRole("button", { name: "admin.user.show.editRoles" }), + screen.queryByRole("button", { name: "admin.user.show.personal.edit" }), + screen.queryByRole("button", { name: "admin.user.show.preferences.edit" }), ]; describe("UserDetailContent edit controls for a staff target", () => { @@ -117,7 +101,7 @@ describe("UserDetailContent edit controls for a staff target", () => { it("hides them for a moderator when the viewer lacks users:can_edit_admin", async () => { await mount({ permissions: permissionSet("can_edit"), - user: userFixture(true), + user: adminUserFixture({ isStaff: true }), }); for (const control of editControls()) { @@ -128,7 +112,7 @@ describe("UserDetailContent edit controls for a staff target", () => { it("shows them for a moderator when the viewer holds users:can_edit_admin", async () => { await mount({ permissions: permissionSet("can_edit", "can_edit_admin"), - user: userFixture(true), + user: adminUserFixture({ isStaff: true }), }); for (const control of editControls()) { @@ -139,11 +123,56 @@ describe("UserDetailContent edit controls for a staff target", () => { it("shows them for a member with users:can_edit alone", async () => { await mount({ permissions: permissionSet("can_edit"), - user: userFixture(false), + user: adminUserFixture(), }); for (const control of editControls()) { expect(control).not.toBeNull(); } }); + + it("offers to verify the email only while it is unverified", async () => { + await mount({ + permissions: permissionSet("can_edit"), + user: adminUserFixture({ emailVerified: false }), + }); + + expect( + screen.getByRole("button", { name: "admin.user.show.verify.action" }), + ).not.toBeNull(); + expect( + screen.getByText("admin.user.show.badges.unverified"), + ).not.toBeNull(); + }); + + it("does not offer to verify an already verified email", async () => { + await mount({ + permissions: permissionSet("can_edit"), + user: adminUserFixture(), + }); + + expect( + screen.queryByRole("button", { name: "admin.user.show.verify.action" }), + ).toBeNull(); + }); + + it("shows the activity tab first and switches to the notifications tab", async () => { + await mount({ + permissions: permissionSet("can_edit"), + user: adminUserFixture(), + }); + + expect(screen.getByText("timeline slot")).not.toBeNull(); + expect(screen.getByText("connected accounts slot")).not.toBeNull(); + expect(screen.getByText("devices slot")).not.toBeNull(); + expect(screen.queryByText("notifications slot")).toBeNull(); + + fireEvent.click( + screen.getByRole("tab", { + name: /admin\.user\.show\.tabs\.notifications/, + }), + ); + + expect(await screen.findByText("notifications slot")).not.toBeNull(); + }); }); diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.tsx index 1d3ad51b3..91325becc 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.tsx @@ -1,13 +1,9 @@ -import { Link } from "@tanstack/react-router"; -import { ExternalLinkIcon } from "lucide-react"; +import { BellIcon, HistoryIcon, LockIcon } from "lucide-react"; import { useTranslations } from "use-intl"; +import type { VerifyAdminUserEmail } from "@/views/admin/views/core/users/list/users-table-content"; import type { AdminRoleSearch } from "@/views/admin/views/core/users/roles/roles-query"; -import { Avatar } from "@/components/avatar"; -import { DateFormat } from "@/components/date-format"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, @@ -15,7 +11,6 @@ import { TabsPanels, TabsTrigger, } from "@/components/ui/tabs"; -import { UserCoverImage } from "@/components/user-cover-image"; import type { UpdateAdminUser } from "./user-fields-content"; import type { @@ -25,165 +20,100 @@ import type { import type { AdminUserDetail } from "./user-query"; import type { UpdateAdminUserRoles } from "./user-roles-content"; -import { - EditNameCodeContent, - EditUserFieldContent, -} from "./user-fields-content"; -import { AdminUserImageDialog } from "./user-images-content"; +import { UserIdentityCard } from "./user-identity-card"; +import { UserPersonalCard, UserPreferencesCard } from "./user-profile-cards"; import { UserRolesCardContent } from "./user-roles-content"; export interface UserDetailProps { canEdit: boolean; + connectedAccounts: React.ReactNode; + devices: React.ReactNode; + notifications: React.ReactNode; onRemoveImage: RemoveAdminUserImage; onUpdate: UpdateAdminUser; onUpdateRoles: UpdateAdminUserRoles; onUploadImage: UploadAdminUserImage; + onVerifyEmail: VerifyAdminUserEmail; searchRoles: AdminRoleSearch; + security: React.ReactNode; timeline: React.ReactNode; user: AdminUserDetail; } export const UserDetailContent = ({ canEdit, + connectedAccounts, + devices, + notifications, onRemoveImage, onUpdate, onUpdateRoles, onUploadImage, + onVerifyEmail, searchRoles, + security, timeline, user, }: UserDetailProps) => { - const t = useTranslations("admin.user.show"); - const tSearch = useTranslations("core.search"); + const t = useTranslations("admin.user.show.tabs"); return ( - - - - {tSearch("userTab.overview")} - - - {tSearch("userTab.timeline")} - - - - - -
    - -
    - - {user.coverUrl ? null : ( - {t("coverPlaceholder")} - )} - {canEdit && ( -
    - -
    - )} -
    - - -
    -
    - - {canEdit && ( -
    - -
    - )} -
    -
    - - - -
    - - @{user.nameCode} - - {canEdit && ( - - )} -
    - -
    - -
    - -

    - {t("joined")} -

    - -
    - -
    -
    -
    - - -
    -
    - - {timeline} -
    -
    +
    + + + +

    {t("detailsLabel")}

    +
    + + + + {t("activity")} + + + + {t("notifications")} + + + + {t("securityShort")} + {t("security")} + + +
    + + {timeline} + {notifications} + {security} + +
    +
    ); }; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-devices.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-devices.tsx new file mode 100644 index 000000000..419038660 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-devices.tsx @@ -0,0 +1,315 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + LaptopIcon, + LogOutIcon, + MonitorSmartphoneIcon, + SmartphoneIcon, + TabletIcon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardHeader, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +import type { AdminUserDevice } from "./user-account-query"; +import type { AdminUserDetail } from "./user-query"; + +import { useFailureToast } from "./use-failure-toast"; +import { + revokeAdminUserDevice, + revokeAdminUserDevices, +} from "./user-account-mutations"; +import { + adminUserDevicesQueryKey, + adminUserDevicesQueryOptions, +} from "./user-account-query"; +import { DetailCardTitle } from "./user-profile-cards"; + +const VISIBLE_DEVICES = 3; + +const DEVICE_ICONS = { + desktop: LaptopIcon, + mobile: SmartphoneIcon, + tablet: TabletIcon, +}; + +const useDeviceName = () => { + const t = useTranslations("admin.user.show.devices"); + + return (device: AdminUserDevice) => + device.browser && device.os + ? t("deviceName", { browser: device.browser, os: device.os }) + : (device.browser ?? device.os ?? t("unknownDevice")); +}; + +const RevokeDeviceButton = ({ + device, + onRevoke, + userName, +}: { + device: AdminUserDevice; + onRevoke: () => Promise; + userName: string; +}) => { + const t = useTranslations("admin.user.show.devices"); + const deviceName = useDeviceName()(device); + const label = t("signOutLabel", { device: deviceName }); + + return ( + + } + onSubmit={async ({ onClose }) => { + await onRevoke(); + onClose(); + }} + textSubmit={t("signOutSubmit")} + title={t("signOutTitle")} + > + } + > + + + + {t("signOut")} + + ); +}; + +const DeviceRow = ({ + canEdit, + device, + onRevoke, + userName, +}: { + canEdit: boolean; + device: AdminUserDevice; + onRevoke: () => Promise; + userName: string; +}) => { + const t = useTranslations("admin.user.show.devices"); + const deviceName = useDeviceName(); + const Icon = DEVICE_ICONS[device.deviceType]; + + return ( +
  • + + + +
    + + + {deviceName(device)} + + {device.sessionKinds.includes("admin") && ( + {t("admincp")} + )} + + + {device.ipAddress} + {" · "} + + +
    + {canEdit && ( + + )} +
  • + ); +}; + +const SignOutAllButton = ({ + count, + onSignOutAll, + userName, +}: { + count: number; + onSignOutAll: () => Promise; + userName: string; +}) => { + const t = useTranslations("admin.user.show.devices"); + + return ( + } + onSubmit={async ({ onClose }) => { + if (await onSignOutAll()) onClose(); + }} + textSubmit={t("signOutAll")} + title={t("signOutAllTitle", { name: userName })} + > + + + ); +}; + +const DeviceList = ({ + canEdit, + devices, + onRevoke, + userName, +}: { + canEdit: boolean; + devices: AdminUserDevice[]; + onRevoke: (device: AdminUserDevice) => Promise; + userName: string; +}) => { + const t = useTranslations("admin.user.show.devices"); + const [showAll, setShowAll] = React.useState(false); + + return ( + <> +
      + {(showAll ? devices : devices.slice(0, VISIBLE_DEVICES)).map(device => ( + { + await onRevoke(device); + }} + userName={userName} + /> + ))} +
    + {devices.length > VISIBLE_DEVICES && ( + + )} + + ); +}; + +export const UserDevicesCard = ({ + adminUserId, + canEdit, + user, +}: { + adminUserId: AdminIdentity; + canEdit: boolean; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.devices"); + const queryClient = useQueryClient(); + const deviceName = useDeviceName(); + const showFailure = useFailureToast(); + const key = { adminUserId, userId: user.id }; + const { + data: devices, + isError, + isPending, + } = useQuery(adminUserDevicesQueryOptions(key)); + + const refresh = async () => { + await queryClient.invalidateQueries({ + queryKey: adminUserDevicesQueryKey(key), + }); + }; + + const revokeDevice = async (device: AdminUserDevice) => { + const result = await revokeAdminUserDevice(user.id, device.publicId); + if ("error" in result) { + showFailure(); + + return; + } + await refresh(); + toast.success(t("signedOut"), { + description: t("signedOutDesc", { device: deviceName(device) }), + }); + }; + + const signOutAll = async () => { + const result = await revokeAdminUserDevices(user.id); + if ("error" in result) { + showFailure(); + + return false; + } + await refresh(); + toast.success(t("signedOutAll"), { + description: t("signedOutAllDesc", { name: user.name }), + }); + + return true; + }; + + return ( + + + + {t("title")} + + {canEdit && devices && devices.length > 0 && ( + + + + )} + + + {isPending ? ( +
    + + + +
    + ) : isError ? ( +

    + {t("loadError")} +

    + ) : devices.length === 0 ? ( +

    + {t("empty")} +

    + ) : ( + + )} +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-fields-content.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-fields-content.tsx index 40a09f495..8370e2eea 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-fields-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-fields-content.tsx @@ -2,29 +2,18 @@ import { CheckIcon, LinkIcon, MailIcon, PencilIcon, XIcon } from "lucide-react"; import React from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; -import { z } from "zod"; -import type { AutoFormOnSubmit } from "@/components/form/auto-form"; import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; import type { AdminUserUpdateInput } from "@/views/admin/views/core/users/users-mutations"; -import { AutoForm } from "@/components/form/auto-form"; -import { AutoFormInput } from "@/components/form/fields/input"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, - useDialog, -} from "@/components/ui/dialog"; -import { setFormFieldError } from "@/components/ui/form"; +import { Dialog, DialogTrigger } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { TooltipWithContent } from "@/components/ui/tooltip"; +import { EditSheetContent } from "./edit-sheet-content"; + /** How the page performs a user update. Supplied by whichever app mounts this. */ export type UpdateAdminUser = ( id: number, @@ -119,26 +108,30 @@ export const EditUserFieldContent = ({ type={type} value={draft} /> - - + + + + + + ); } @@ -154,107 +147,29 @@ export const EditUserFieldContent = ({ )} {canEdit && ( - + + + )} ); }; -const NameCodeForm = ({ - id, - nameCode, - onUpdate, -}: { - id: number; - nameCode: string; - onUpdate: UpdateAdminUser; -}) => { - const t = useTranslations("admin.user.show"); - const tError = useTranslations("core.global.errors"); - const { setIsDirty, setOpen } = useDialog(); - - const formSchema = z.object({ - currentNameCode: z - .string({ message: tError("field_required") }) - .refine(value => value === nameCode, t("nameCodeConfirmMismatch")) - .default(""), - newNameCode: z - .string({ message: tError("field_required") }) - .min(3, tError("field_min_length", { min: 3 })) - .max(255) - .regex(/^[a-zA-Z0-9-]+$/, t("nameCodeInvalid")) - .refine(value => value !== nameCode, t("nameCodeSame")) - .default(""), - }); - - const onSubmit: AutoFormOnSubmit = async ( - values, - form, - ) => { - const result = await onUpdate(id, { nameCode: values.newNameCode }); - - if ("data" in result) { - setIsDirty?.(false); - setOpen?.(false); - toast.success(t("updateSuccess")); - - return; - } - - if (result.error.status === 409) { - setFormFieldError(form, "newNameCode", t("nameCodeExists")); - - return; - } - - toast.error(tError("title"), { - description: tError("internal_server_error"), - }); - }; - - return ( - ( - {chunks}, - nameCode: () => {nameCode}, - })} - {...props} - /> - ), - id: "currentNameCode", - }, - { - component: props => ( - - ), - id: "newNameCode", - }, - ]} - formSchema={formSchema} - mode="all" - onSubmit={onSubmit} - submitButtonProps={{ - children: t("saveNameCode"), - variant: "destructive", - }} - /> - ); -}; +const NameCodeForm = React.lazy(async () => + import("./name-code-form").then(module => ({ + default: module.NameCodeForm, + })), +); export const EditNameCodeContent = ({ id, @@ -269,34 +184,36 @@ export const EditNameCodeContent = ({ return ( - - } - > - - + + + } + > + + + - - - + {t("editNameCode")} - - {t("editNameCodeDesc")} - - + + } + > {t("editNameCodeWarningTitle")} {t("editNameCodeWarning")} - + ); }; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-identity-card.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-identity-card.tsx new file mode 100644 index 000000000..bdebbf67c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-identity-card.tsx @@ -0,0 +1,206 @@ +import { Link } from "@tanstack/react-router"; +import { + BadgeCheckIcon, + ExternalLinkIcon, + MailWarningIcon, + ShieldIcon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { VerifyAdminUserEmail } from "@/views/admin/views/core/users/list/users-table-content"; + +import { Avatar } from "@/components/avatar"; +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { UserCoverImage } from "@/components/user-cover-image"; + +import type { UpdateAdminUser } from "./user-fields-content"; +import type { + RemoveAdminUserImage, + UploadAdminUserImage, +} from "./user-images-content"; +import type { AdminUserDetail } from "./user-query"; + +import { + EditNameCodeContent, + EditUserFieldContent, +} from "./user-fields-content"; +import { AdminUserImageDialog } from "./user-images-content"; + +const VerifyEmailButton = ({ + onVerifyEmail, + user, +}: { + onVerifyEmail: VerifyAdminUserEmail; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.verify"); + const tError = useTranslations("core.global.errors"); + const [isPending, startTransition] = React.useTransition(); + + return ( + + ); +}; + +export const UserIdentityCard = ({ + canEdit, + onRemoveImage, + onUpdate, + onUploadImage, + onVerifyEmail, + user, +}: { + canEdit: boolean; + onRemoveImage: RemoveAdminUserImage; + onUpdate: UpdateAdminUser; + onUploadImage: UploadAdminUserImage; + onVerifyEmail: VerifyAdminUserEmail; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show"); + + return ( + +
    + + {user.coverUrl ? null : ( + {t("coverPlaceholder")} + )} + {canEdit && ( +
    + +
    + )} +
    + + +
    + + {canEdit && ( +
    + +
    + )} +
    + +
    + +
    + + @{user.nameCode} + + {canEdit && ( + + )} +
    +
    + {user.isStaff && ( + + + {t("badges.staff")} + + )} + {user.emailVerified ? ( + + + {t("badges.verified")} + + ) : ( + + + {t("badges.unverified")} + + )} +
    +
    + +
    + +

    + {t("joined")} +

    +
    + +
    + {canEdit && !user.emailVerified && ( + + )} + +
    +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-notifications.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-notifications.tsx new file mode 100644 index 000000000..2a40b980d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-notifications.tsx @@ -0,0 +1,149 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; +import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { NotificationTypeItem } from "@/views/notifications/settings/type-item"; + +import type { AdminUserNotificationPatch } from "./user-account-mutations"; +import type { AdminUserDetail } from "./user-query"; + +import { useFailureToast } from "./use-failure-toast"; +import { updateAdminUserNotificationPreferences } from "./user-account-mutations"; +import { + adminUserNotificationsQueryKey, + adminUserNotificationsQueryOptions, +} from "./user-account-query"; + +interface NotificationGroup { + label: string; + types: NotificationPreferenceTypeView[]; +} + +const groupNotificationTypes = ( + types: NotificationPreferenceTypeView[], +): NotificationGroup[] => [ + ...types + .reduce((map, type) => { + const group = map.get(type.category) ?? { + label: type.categoryLabel, + types: [], + }; + group.types.push(type); + + return map.set(type.category, group); + }, new Map()) + .values(), +]; + +export const UserNotificationsPanel = ({ + adminUserId, + canEdit, + user, +}: { + adminUserId: AdminIdentity; + canEdit: boolean; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.notifications"); + const showFailure = useFailureToast(); + const queryClient = useQueryClient(); + const key = { adminUserId, userId: user.id }; + const queryKey = adminUserNotificationsQueryKey(key); + const { data, isError, isPending } = useQuery( + adminUserNotificationsQueryOptions(key), + ); + const [openId, setOpenId] = React.useState(null); + + const save = async (typeId: string, patch: AdminUserNotificationPatch) => { + const previous = + queryClient.getQueryData(queryKey); + queryClient.setQueryData( + queryKey, + current => + current?.map(type => + type.id === typeId + ? { ...type, value: { ...type.value, ...patch } } + : type, + ), + ); + + const result = await updateAdminUserNotificationPreferences(user.id, { + [typeId]: patch, + }); + + if ("error" in result) { + queryClient.setQueryData(queryKey, previous); + showFailure(); + + return; + } + + queryClient.setQueryData(queryKey, result.data); + toast.success(t("saved"), { + description: t("savedDesc", { name: user.name }), + }); + }; + + if (isPending) { + return ( +
    + + +
    + ); + } + + if (isError) { + return ( +

    + {t("loadError")} +

    + ); + } + + const groups = groupNotificationTypes(data); + + return ( +
    +

    + {t("desc", { name: user.name })} +

    + {groups.length === 0 ? ( +

    {t("empty")}

    + ) : ( +
      + {groups.map(group => ( + + {groups.length > 1 ? ( +
    • +

      {group.label}

      +
    • + ) : null} + {group.types.map(type => ( + { + if (!canEdit) return; + void save(type.id, patch); + }} + onToggle={() => { + setOpenId(current => + current === type.id ? null : type.id, + ); + }} + type={canEdit ? type : { ...type, locked: true }} + /> + ))} +
      + ))} +
    + )} +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-profile-cards.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-profile-cards.tsx new file mode 100644 index 000000000..5d5a5749e --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-profile-cards.tsx @@ -0,0 +1,209 @@ +import { IdCardIcon, PencilIcon, SlidersHorizontalIcon } from "lucide-react"; +import React from "react"; +import { useFormatter, useTranslations } from "use-intl"; + +import { useLanguages } from "@/components/languages-provider"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Dialog, DialogTrigger } from "@/components/ui/dialog"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { timeZoneLabel } from "@/views/auth/settings/overview/time-zone-update"; + +import type { UpdateAdminUser } from "./user-fields-content"; +import type { AdminUserDetail } from "./user-query"; + +import { EditSheetContent } from "./edit-sheet-content"; + +const PersonalForm = React.lazy(async () => + import("./user-profile-forms").then(module => ({ + default: module.PersonalForm, + })), +); + +const PreferencesForm = React.lazy(async () => + import("./user-profile-forms").then(module => ({ + default: module.PreferencesForm, + })), +); + +export const DetailRow = ({ + children, + label, +}: { + children: React.ReactNode; + label: string; +}) => ( +
    +
    {label}
    +
    + {children} +
    +
    +); + +export const DetailCardTitle = ({ + children, + icon: Icon, +}: { + children: React.ReactNode; + icon: React.ElementType<{ className?: string }>; +}) => ( + + + {children} + +); + +const EditGroupDialog = ({ + children, + description, + label, + title, +}: { + children: React.ReactNode; + description: string; + label: string; + title: string; +}) => ( + + + } + > + + + + + {children} + + +); + +export const UserPersonalCard = ({ + canEdit, + onUpdate, + user, +}: { + canEdit: boolean; + onUpdate: UpdateAdminUser; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.personal"); + const format = useFormatter(); + const realName = [user.firstName, user.lastName].filter(Boolean).join(" "); + const notSet = {t("notSet")}; + + return ( + + + {t("title")} + {canEdit && ( + + + + + + )} + + +
    + + #{user.id} + + + {realName ? ( + <> + {realName} + {!user.showRealName && ( + + {" "} + · {t("hidden")} + + )} + + ) : ( + notSet + )} + + {user.headline ?? notSet} + + {user.phone ? ( + {user.phone} + ) : ( + notSet + )} + + + {user.birthday + ? format.dateTime(new Date(user.birthday), { + dateStyle: "long", + timeZone: "UTC", + }) + : notSet} + +
    +
    +
    + ); +}; + +export const UserPreferencesCard = ({ + canEdit, + onUpdate, + user, +}: { + canEdit: boolean; + onUpdate: UpdateAdminUser; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.preferences"); + const languages = useLanguages(); + const language = + languages.find(item => item.code === user.language)?.name ?? user.language; + + return ( + + + + {t("title")} + + {canEdit && ( + + + + + + )} + + +
    + {language} + + {user.timeZone ? ( + timeZoneLabel(user.timeZone) + ) : ( + {t("timeZoneAuto")} + )} + + + {user.newsletter ? t("subscribed") : t("notSubscribed")} + +
    +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-profile-forms.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-profile-forms.tsx new file mode 100644 index 000000000..1e5dfae9a --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-profile-forms.tsx @@ -0,0 +1,272 @@ +import React from "react"; +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 { AutoFormCombobox } from "@/components/form/fields/combobox"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { AutoFormSelect } from "@/components/form/fields/select"; +import { AutoFormSwitch } from "@/components/form/fields/switch"; +import { useLanguages } from "@/components/languages-provider"; +import { useDialog } from "@/components/ui/dialog"; +import { + USER_FIRST_NAME_MAX_LENGTH, + USER_HEADLINE_MAX_LENGTH, + USER_LAST_NAME_MAX_LENGTH, + USER_PHONE_MAX_LENGTH, + USER_PHONE_PATTERN, +} from "@/lib/user-personal-information"; +import { + supportedTimeZones, + timeZoneLabel, +} from "@/views/auth/settings/overview/time-zone-update"; + +import type { UpdateAdminUser } from "./user-fields-content"; +import type { AdminUserDetail } from "./user-query"; + +import { useFailureToast } from "./use-failure-toast"; + +const AUTOMATIC_TIME_ZONE = "auto"; + +const toDateInput = (value: AdminUserDetail["birthday"]): string => { + if (!value) return ""; + const date = new Date(value); + + return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10); +}; + +const emptyToNull = (value: string): null | string => { + const trimmed = value.trim(); + + return trimmed === "" ? null : trimmed; +}; + +export const PersonalForm = ({ + onUpdate, + user, +}: { + onUpdate: UpdateAdminUser; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.personal"); + const tError = useTranslations("core.global.errors"); + const { setIsDirty, setOpen } = useDialog(); + const showFailure = useFailureToast(); + + const formSchema = z.object({ + firstName: z + .string() + .max(USER_FIRST_NAME_MAX_LENGTH) + .default(user.firstName ?? ""), + lastName: z + .string() + .max(USER_LAST_NAME_MAX_LENGTH) + .default(user.lastName ?? ""), + headline: z + .string() + .max(USER_HEADLINE_MAX_LENGTH) + .default(user.headline ?? ""), + phone: z + .string() + .max(USER_PHONE_MAX_LENGTH) + .refine(value => value === "" || USER_PHONE_PATTERN.test(value), { + message: tError("field_invalid_phone"), + }) + .default(user.phone ?? ""), + birthday: z.string().default(toDateInput(user.birthday)), + showRealName: z.boolean().default(user.showRealName), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const result = await onUpdate(user.id, { + birthday: values.birthday === "" ? null : values.birthday, + firstName: emptyToNull(values.firstName), + headline: emptyToNull(values.headline), + lastName: emptyToNull(values.lastName), + phone: emptyToNull(values.phone), + showRealName: values.showRealName, + }); + + if ("error" in result) { + showFailure(); + + return; + } + + setIsDirty?.(false); + setOpen?.(false); + toast.success(t("saved"), { + description: t("savedDesc", { name: user.name }), + }); + }; + + return ( + ( + + ), + }, + { + id: "lastName", + component: props => ( + + ), + }, + { + id: "headline", + component: props => ( + + ), + }, + { + id: "phone", + component: props => ( + + ), + }, + { + id: "birthday", + component: props => ( + + ), + }, + { + id: "showRealName", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("save") }} + /> + ); +}; + +export const PreferencesForm = ({ + onUpdate, + user, +}: { + onUpdate: UpdateAdminUser; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.preferences"); + const { setIsDirty, setOpen } = useDialog(); + const showFailure = useFailureToast(); + const languages = useLanguages(); + const zones = React.useMemo(() => supportedTimeZones(), []); + const options = languages.some(language => language.code === user.language) + ? languages + : [...languages, { code: user.language, name: user.language }]; + const codes = options.map(language => language.code); + const [firstCode = user.language, ...restCodes] = codes; + + const formSchema = z.object({ + language: z.enum([firstCode, ...restCodes]).default(user.language), + timeZone: z + .enum([AUTOMATIC_TIME_ZONE, ...zones]) + .default(user.timeZone ?? AUTOMATIC_TIME_ZONE), + newsletter: z.boolean().default(user.newsletter), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const result = await onUpdate(user.id, { + language: values.language, + newsletter: values.newsletter, + timeZone: + values.timeZone === AUTOMATIC_TIME_ZONE ? null : values.timeZone, + }); + + if ("error" in result) { + showFailure(); + + return; + } + + setIsDirty?.(false); + setOpen?.(false); + toast.success(t("saved"), { + description: t("savedDesc", { name: user.name }), + }); + }; + + return ( + ( + ({ + label: language.name, + value: language.code, + }))} + /> + ), + }, + { + id: "timeZone", + component: props => ( + ({ + label: timeZoneLabel(zone), + value: zone, + })), + ]} + /> + ), + }, + { + id: "newsletter", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("save") }} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts b/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts index 76c3451d1..f3bb1f6e4 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts @@ -37,16 +37,22 @@ export interface AdminUserDetail { createdAt: Date | string; email: string; emailVerified: boolean; + firstName: null | string; + headline: null | string; id: number; imagePolicy: UserImagePolicy; isStaff: boolean; language: string; + lastName: null | string; name: string; nameCode: string; newsletter: boolean; + phone: null | string; role: AdminUserRole; roleId: number; secondaryRoles: AdminUserRole[]; + showRealName: boolean; + timeZone: null | string; } export type AdminUserFetcher = (id: string) => Promise; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-roles-content.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-roles-content.tsx index bea7fab8a..06ad446d2 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-roles-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-roles-content.tsx @@ -37,22 +37,16 @@ import { CommandItem, CommandList, } from "@/components/ui/command"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - useDialog, -} from "@/components/ui/dialog"; +import { Dialog, DialogTrigger, useDialog } from "@/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Spinner } from "@/components/ui/spinner"; +import { TooltipWithContent } from "@/components/ui/tooltip"; + +import { EditSheetContent } from "./edit-sheet-content"; export type UpdateAdminUserRoles = ( id: number, @@ -340,11 +334,11 @@ const EditRolesForm = ({ /> - +
    - +
    ); }; @@ -376,27 +370,29 @@ export const UserRolesCardContent = ({ {canEdit && ( - - } - > - - + + + } + > + + + - - - + {t("editRoles")} - - {t("editRolesDesc")} - - + + } + > - + )} diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-security-forms.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-security-forms.tsx new file mode 100644 index 000000000..39e3a2f42 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-security-forms.tsx @@ -0,0 +1,148 @@ +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { useDialog } from "@/components/ui/dialog"; +import { PASSKEY_NAME_MAX_LENGTH } from "@/lib/passkey"; + +import type { AdminUserPasskey } from "./user-account-query"; +import type { AdminUserDetail } from "./user-query"; + +import { useFailureToast } from "./use-failure-toast"; +import { + renameAdminUserPasskey, + setAdminUserPassword, +} from "./user-account-mutations"; + +const PASSWORD_MIN_LENGTH = 8; + +export const PasswordForm = ({ + onSaved, + user, +}: { + onSaved: () => Promise; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.security"); + const { setIsDirty, setOpen } = useDialog(); + const showFailure = useFailureToast(); + const formSchema = z + .object({ + password: z + .string() + .min( + PASSWORD_MIN_LENGTH, + t("passwordMin", { min: PASSWORD_MIN_LENGTH }), + ) + .default(""), + confirm: z.string().default(""), + }) + .refine(values => values.password === values.confirm, { + message: t("passwordMismatch"), + path: ["confirm"], + }); + + return ( + ( + + ), + }, + { + id: "confirm", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + onSubmit={async values => { + const result = await setAdminUserPassword(user.id, values.password); + if ("error" in result) { + showFailure(); + + return; + } + await onSaved(); + setIsDirty?.(false); + setOpen?.(false); + toast.success(t("passwordSaved"), { + description: t("passwordSavedDesc", { name: user.name }), + }); + }} + submitButtonProps={{ children: t("passwordSubmit") }} + /> + ); +}; + +export const RenamePasskeyForm = ({ + onSaved, + passkey, + user, +}: { + onSaved: () => Promise; + passkey: AdminUserPasskey; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.security"); + const { setIsDirty, setOpen } = useDialog(); + const showFailure = useFailureToast(); + const formSchema = z.object({ + name: z + .string() + .trim() + .min(1) + .max(PASSKEY_NAME_MAX_LENGTH) + .default(passkey.name), + }); + + return ( + ( + + ), + }, + ]} + formSchema={formSchema} + onSubmit={async values => { + const result = await renameAdminUserPasskey( + user.id, + passkey.id, + values.name, + ); + if ("error" in result) { + showFailure(); + + return; + } + await onSaved(); + setIsDirty?.(false); + setOpen?.(false); + toast.success(t("passkeyRenamed")); + }} + submitButtonProps={{ children: t("passkeyRenameSubmit") }} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-security.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-security.tsx new file mode 100644 index 000000000..d1b076c8e --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-security.tsx @@ -0,0 +1,377 @@ +import { + useQuery, + useQueryClient, + type UseQueryResult, +} from "@tanstack/react-query"; +import { + FingerprintIcon, + LockIcon, + PencilIcon, + Trash2Icon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; +import type { SsoConnectionsApi } from "@/views/auth/settings/sso/sso-connections-query"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { DateFormat } from "@/components/date-format"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Dialog, DialogTrigger } from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipWithContent, +} from "@/components/ui/tooltip"; + +import type { AdminUserPasskey } from "./user-account-query"; +import type { AdminUserDetail } from "./user-query"; + +import { EditSheetContent } from "./edit-sheet-content"; +import { useFailureToast } from "./use-failure-toast"; +import { deleteAdminUserPasskey } from "./user-account-mutations"; +import { + adminUserPasskeysQueryOptions, + adminUserSsoQueryOptions, + countSignInMethods, +} from "./user-account-query"; +import { DetailCardTitle } from "./user-profile-cards"; +import { adminUserQueryKey } from "./user-query"; + +const PasswordForm = React.lazy(async () => + import("./user-security-forms").then(module => ({ + default: module.PasswordForm, + })), +); + +const RenamePasskeyForm = React.lazy(async () => + import("./user-security-forms").then(module => ({ + default: module.RenamePasskeyForm, + })), +); + +const SubHeading = ({ + children, + count, +}: { + children: string; + count?: number; +}) => ( +

    + {children} + {count === undefined ? null : {count}} +

    +); + +const PasskeyRow = ({ + canEdit, + isLastMethod, + onSaved, + passkey, + user, +}: { + canEdit: boolean; + isLastMethod: boolean; + onSaved: () => Promise; + passkey: AdminUserPasskey; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.security"); + const showFailure = useFailureToast(); + + return ( +
  • + + + +
    + + {passkey.name} + + + {passkey.backedUp ? t("passkeySynced") : t("passkeyDeviceOnly")} + {" · "} + {passkey.lastUsedAt ? ( + <> + {t("passkeyUsed")} + + ) : ( + t("passkeyNeverUsed") + )} + +
    + {canEdit && ( +
    + + + + } + > + + + + + + + + {isLastMethod ? ( + + + + + + ) : ( + + } + onSubmit={async ({ onClose }) => { + const result = await deleteAdminUserPasskey( + user.id, + passkey.id, + ); + if ("error" in result) { + if (result.error.status === 409) { + toast.error(t("lastMethod"), { + description: t("lastMethodDesc"), + }); + } else { + showFailure(); + } + + return; + } + await onSaved(); + toast.success(t("passkeyDeleted"), { + description: t("passkeyDeletedDesc", { + passkey: passkey.name, + }), + }); + onClose(); + }} + textSubmit={t("passkeyDeleteSubmit")} + title={t("passkeyDeleteTitle")} + > + + } + > + + + + {t("passkeyDelete")} + + )} +
    + )} +
  • + ); +}; + +const PasswordSection = ({ + canEdit, + onSaved, + sso, + user, +}: { + canEdit: boolean; + onSaved: () => Promise; + sso: UseQueryResult; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.security"); + const hasPassword = sso.data?.signIn.hasPassword === true; + + return ( +
    + {t("password")} +
    + + + + + {sso.isPending ? ( + + ) : sso.isError ? ( + + {t("passwordLoadError")} + + ) : !sso.data.signIn.passwordEnabled ? ( + + {t("passwordDisabled")} + + ) : hasPassword ? ( + t("passwordSet") + ) : ( + t("passwordNotSet") + )} + + {canEdit && sso.data?.signIn.passwordEnabled === true && ( + + }> + {hasPassword ? t("passwordChange") : t("passwordAdd")} + + + + + + )} +
    +
    + ); +}; + +const PasskeysSection = ({ + canEdit, + enabled, + isCapabilityError, + isLastMethod, + onSaved, + query, + user, +}: { + canEdit: boolean; + enabled: boolean; + isCapabilityError: boolean; + isLastMethod: boolean; + onSaved: () => Promise; + query: UseQueryResult; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.security"); + + return ( +
    + {t("passkeys")} + {!enabled ? ( +

    + {t("passkeysDisabled")} +

    + ) : isCapabilityError || query.isError ? ( +

    + {t("passkeysLoadError")} +

    + ) : query.isPending ? ( +
    + + +
    + ) : query.data.length === 0 ? ( +

    + {t("passkeysEmpty")} +

    + ) : ( +
      + {query.data.map(passkey => ( + + ))} +
    + )} +
    + ); +}; + +export const UserSecurityPanel = ({ + adminUserId, + canEdit, + user, +}: { + adminUserId: AdminIdentity; + canEdit: boolean; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.security"); + const queryClient = useQueryClient(); + const key = { adminUserId, userId: user.id }; + const sso = useQuery(adminUserSsoQueryOptions(key)); + const passkeysEnabled = sso.data?.signIn.passkeysEnabled === true; + const passkeys = useQuery({ + ...adminUserPasskeysQueryOptions(key), + enabled: passkeysEnabled, + }); + const isLastMethod = + countSignInMethods({ + passkeys: passkeys.data?.length ?? 0, + sso: sso.data, + }) <= 1; + + const refresh = async () => { + await queryClient.invalidateQueries({ + queryKey: adminUserQueryKey({ adminUserId, id: String(user.id) }), + }); + }; + + return ( + + + {t("title")} + + + + + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts b/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts index 64226d046..8984c101c 100644 --- a/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts +++ b/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts @@ -9,11 +9,20 @@ import { } from "@/views/admin/views/core/shared/admin-mutation"; export interface AdminUserUpdateInput { + birthday?: null | string; email?: string; + firstName?: null | string; + headline?: null | string; + language?: string; + lastName?: null | string; name?: string; nameCode?: string; + newsletter?: boolean; + phone?: null | string; roleId?: number; secondaryRoleIds?: number[]; + showRealName?: boolean; + timeZone?: null | string; } export interface AdminUserUpdated { diff --git a/packages/vitnode/src/views/auth/settings/nav-content.tsx b/packages/vitnode/src/views/auth/settings/nav-content.tsx index 9c995012e..cdf595474 100644 --- a/packages/vitnode/src/views/auth/settings/nav-content.tsx +++ b/packages/vitnode/src/views/auth/settings/nav-content.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router"; import { cn } from "cn"; import { + BellIcon, KeyRoundIcon, LinkIcon, MenuIcon, @@ -29,6 +30,7 @@ const ICONS: Record< React.ComponentType<{ className?: string }> > = { devices: MonitorSmartphoneIcon, + notifications: BellIcon, overview: UserRoundIcon, security: KeyRoundIcon, sso: LinkIcon, diff --git a/packages/vitnode/src/views/auth/settings/overview/overview.tsx b/packages/vitnode/src/views/auth/settings/overview/overview.tsx index 6aeebb89b..e3889ff1b 100644 --- a/packages/vitnode/src/views/auth/settings/overview/overview.tsx +++ b/packages/vitnode/src/views/auth/settings/overview/overview.tsx @@ -13,6 +13,7 @@ import { SelfUserImageDialog } from "@/views/profile/images/self-image-dialog"; import type { PersonalInformationUser } from "./personal-content"; import type { UpdatePersonalInformation } from "./personal-update"; +import type { UpdateTimeZone } from "./time-zone-update"; import { SETTINGS_ROW, @@ -21,6 +22,7 @@ import { } from "../settings-group"; import { PersonalInformationContent } from "./personal-content"; import { RealNameRow } from "./real-name-row"; +import { TimeZoneGroup } from "./time-zone-row"; export interface SettingsOverviewUser extends PersonalInformationUser { avatarColor: string; @@ -28,17 +30,20 @@ export interface SettingsOverviewUser extends PersonalInformationUser { name: string; nameCode: string; role: ProfileRole; + timeZone: null | string; } export const OverviewSettingsContent = ({ canEditPersonalInfo, editor, personalFields, + onTimeZoneUpdate, onUpdate, user, }: { canEditPersonalInfo: boolean; editor?: UserImageEditor; + onTimeZoneUpdate: UpdateTimeZone; onUpdate: UpdatePersonalInformation; personalFields: PersonalInformationFields; user: SettingsOverviewUser; @@ -102,6 +107,8 @@ export const OverviewSettingsContent = ({ user={user} /> + +
  • {t("nickname")} diff --git a/packages/vitnode/src/views/auth/settings/overview/time-zone-editor.tsx b/packages/vitnode/src/views/auth/settings/overview/time-zone-editor.tsx new file mode 100644 index 000000000..6150f444b --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/overview/time-zone-editor.tsx @@ -0,0 +1,121 @@ +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; + +import { AutoForm, AutoFormSubmitButton } from "@/components/form/auto-form"; +import { AutoFormCombobox } from "@/components/form/fields/combobox"; +import { Button } from "@/components/ui/button"; + +import type { UpdateTimeZone } from "./time-zone-update"; + +import { + deviceTimeZone, + supportedTimeZones, + timeZoneLabel, +} from "./time-zone-update"; + +export const TimeZoneEditor = ({ + onClose, + onUpdate, + value, +}: { + onClose: () => void; + onUpdate: UpdateTimeZone; + value: null | string; +}) => { + const t = useTranslations("core.auth.settings.overview"); + const tGlobal = useTranslations("core.global"); + const tError = useTranslations("core.global.errors"); + const zones = React.useMemo(() => supportedTimeZones(), []); + const device = React.useMemo(() => deviceTimeZone(), []); + const [isUsingDevice, setIsUsingDevice] = React.useState(false); + + const save = async (timeZone: null | string) => { + const result = await onUpdate(timeZone); + + if (result.error) { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + + return; + } + + toast.success(t("timeZoneSaved"), { + description: timeZone + ? t("timeZoneSavedDesc", { timeZone: timeZoneLabel(timeZone) }) + : t("timeZoneAuto"), + }); + onClose(); + }; + + const formSchema = z.object({ + value: z + .enum(zones as [string, ...string[]]) + .default(value ?? device ?? "UTC"), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + await save(values.value); + }; + + return ( + ( + + ), + id: "value", + }, + ]} + formSchema={formSchema} + layout={rendered => ( + <> + {rendered.value} +
    + {device && device !== value ? ( + + ) : ( + + )} +
    + + {t("save")} +
    +
    + + )} + mode="all" + onKeyDown={event => { + if (event.key !== "Escape") return; + event.stopPropagation(); + onClose(); + }} + onSubmit={onSubmit} + /> + ); +}; diff --git a/packages/vitnode/src/views/auth/settings/overview/time-zone-row.tsx b/packages/vitnode/src/views/auth/settings/overview/time-zone-row.tsx new file mode 100644 index 000000000..24875a73f --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/overview/time-zone-row.tsx @@ -0,0 +1,82 @@ +import { ChevronRightIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { Skeleton } from "@/components/ui/skeleton"; + +import type { UpdateTimeZone } from "./time-zone-update"; + +import { + SETTINGS_INTERACTIVE_ROW, + SETTINGS_ROW_LABEL, + SettingsGroup, +} from "../settings-group"; +import { timeZoneLabel } from "./time-zone-update"; + +const TimeZoneEditor = React.lazy(async () => + import("./time-zone-editor").then(module => ({ + default: module.TimeZoneEditor, + })), +); + +const TimeZoneEditorSkeleton = () => ( +
    + +
    + + +
    +
    +); + +export const TimeZoneGroup = ({ + onUpdate, + timeZone, +}: { + onUpdate: UpdateTimeZone; + timeZone: null | string; +}) => { + const t = useTranslations("core.auth.settings.overview"); + const [isEditing, setIsEditing] = React.useState(false); + const rowRef = React.useRef(null); + + const close = () => { + setIsEditing(false); + requestAnimationFrame(() => rowRef.current?.focus()); + }; + + return ( + +
  • + {isEditing ? ( + }> + + + ) : ( + + )} +
  • +
    + ); +}; diff --git a/packages/vitnode/src/views/auth/settings/overview/time-zone-update.ts b/packages/vitnode/src/views/auth/settings/overview/time-zone-update.ts new file mode 100644 index 000000000..7f7fd0ffc --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/overview/time-zone-update.ts @@ -0,0 +1,50 @@ +import { CONFIG_PLUGIN } from "@/config"; +import { fetcherClient } from "@/lib/fetcher-client"; + +export interface UpdateTimeZoneResult { + data?: true; + error?: { status: number }; +} + +export type UpdateTimeZone = ( + timeZone: null | string, +) => Promise; + +export const updateTimeZoneInBrowser: UpdateTimeZone = async timeZone => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: { timeZone } }, + method: "put", + module: "users", + path: "/me/time-zone", + }); + + return response.ok + ? { data: true } + : { error: { status: response.status } }; + } catch { + return { error: { status: 500 } }; + } +}; + +export const timeZoneLabel = (timeZone: string): string => + timeZone.replace(/_/g, " "); + +export const supportedTimeZones = (): string[] => { + try { + const zones = Intl.supportedValuesOf("timeZone"); + + return zones.includes("UTC") ? zones : ["UTC", ...zones]; + } catch { + return ["UTC"]; + } +}; + +export const deviceTimeZone = (): null | string => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return null; + } +}; diff --git a/packages/vitnode/src/views/auth/settings/settings-nav.test.ts b/packages/vitnode/src/views/auth/settings/settings-nav.test.ts index 1d484017e..65219d9f6 100644 --- a/packages/vitnode/src/views/auth/settings/settings-nav.test.ts +++ b/packages/vitnode/src/views/auth/settings/settings-nav.test.ts @@ -6,13 +6,13 @@ describe("visibleSettingsNavItems", () => { it("shows Security while passkeys are on", () => { expect( visibleSettingsNavItems({ passkeys: true }).map(item => item.key), - ).toEqual(["overview", "devices", "security"]); + ).toEqual(["overview", "notifications", "devices", "security"]); }); it("hides Security when passkeys are switched off", () => { expect( visibleSettingsNavItems({ passkeys: false }).map(item => item.key), - ).toEqual(["overview", "devices"]); + ).toEqual(["overview", "notifications", "devices"]); }); it("shows Connected accounts only while an SSO provider is configured", () => { @@ -21,11 +21,11 @@ describe("visibleSettingsNavItems", () => { passkeys: false, sso: [{ id: "google", name: "Google" }], }).map(item => item.key), - ).toEqual(["overview", "devices", "sso"]); + ).toEqual(["overview", "notifications", "devices", "sso"]); expect( visibleSettingsNavItems({ passkeys: false, sso: [] }).map( item => item.key, ), - ).toEqual(["overview", "devices"]); + ).toEqual(["overview", "notifications", "devices"]); }); }); diff --git a/packages/vitnode/src/views/auth/settings/settings-nav.ts b/packages/vitnode/src/views/auth/settings/settings-nav.ts index cbe8001d1..b98ff24b4 100644 --- a/packages/vitnode/src/views/auth/settings/settings-nav.ts +++ b/packages/vitnode/src/views/auth/settings/settings-nav.ts @@ -3,7 +3,8 @@ import { normalizeUrl } from "@/lib/utils"; /** Where the settings screens are rooted, and the overview panel's own URL. */ export const SETTINGS_ROOT_HREF = "/settings"; -export type SettingsNavKey = "devices" | "overview" | "security" | "sso"; +export type SettingsNavKey = + "devices" | "notifications" | "overview" | "security" | "sso"; export interface SettingsNavItem { href: string; @@ -13,6 +14,7 @@ export interface SettingsNavItem { export const SETTINGS_NAV_ITEMS: readonly SettingsNavItem[] = [ { href: SETTINGS_ROOT_HREF, key: "overview" }, + { href: "/settings/notifications", key: "notifications" }, { href: "/settings/devices", key: "devices" }, { href: "/settings/security", key: "security" }, { href: "/settings/sso", key: "sso" }, diff --git a/packages/vitnode/src/views/layouts/theme/header/header-content.tsx b/packages/vitnode/src/views/layouts/theme/header/header-content.tsx index e96c958f2..f72d0525f 100644 --- a/packages/vitnode/src/views/layouts/theme/header/header-content.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/header-content.tsx @@ -14,6 +14,7 @@ export interface HeaderLayoutContentProps extends Omit< mobileUser?: React.ReactNode; moreNavigationLabel: string; navigation: HeaderNavItem[]; + notifications?: React.ReactNode; user?: React.ReactNode; } @@ -23,6 +24,7 @@ export const HeaderLayoutContent = ({ mobileUser, moreNavigationLabel, navigation, + notifications, user, ...props }: HeaderLayoutContentProps) => ( @@ -43,6 +45,7 @@ export const HeaderLayoutContent = ({ />
    + {notifications}
    { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("turns a burst of events into one refresh", () => { + const run = vi.fn(); + const coalescer = createCoalescer(run, { maxWait: 5_000, wait: 1_000 }); + + for (let index = 0; index < 50; index++) { + coalescer.schedule(); + vi.advanceTimersByTime(10); + } + vi.advanceTimersByTime(1_000); + + expect(run).toHaveBeenCalledTimes(1); + }); + + it("still refreshes during an endless stream, at most every maxWait", () => { + const run = vi.fn(); + const coalescer = createCoalescer(run, { maxWait: 5_000, wait: 1_000 }); + + for (let index = 0; index < 120; index++) { + coalescer.schedule(); + vi.advanceTimersByTime(100); + } + + expect(run).toHaveBeenCalledTimes(2); + }); + + it("does nothing once cancelled", () => { + const run = vi.fn(); + const coalescer = createCoalescer(run, { maxWait: 5_000, wait: 1_000 }); + coalescer.schedule(); + coalescer.cancel(); + vi.advanceTimersByTime(10_000); + + expect(run).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vitnode/src/views/notifications/coalesce.ts b/packages/vitnode/src/views/notifications/coalesce.ts new file mode 100644 index 000000000..3bcec6f53 --- /dev/null +++ b/packages/vitnode/src/views/notifications/coalesce.ts @@ -0,0 +1,29 @@ +export const createCoalescer = ( + run: () => void, + { maxWait, wait }: { maxWait: number; wait: number }, +) => { + let timer: ReturnType | undefined; + let firstCallAt: null | number = null; + + const flush = () => { + if (timer) clearTimeout(timer); + timer = undefined; + firstCallAt = null; + run(); + }; + + return { + cancel: () => { + if (timer) clearTimeout(timer); + timer = undefined; + firstCallAt = null; + }, + schedule: () => { + const now = Date.now(); + firstCallAt ??= now; + if (timer) clearTimeout(timer); + const delay = Math.max(0, Math.min(wait, firstCallAt + maxWait - now)); + timer = setTimeout(flush, delay); + }, + }; +}; diff --git a/packages/vitnode/src/views/notifications/notification-item.tsx b/packages/vitnode/src/views/notifications/notification-item.tsx new file mode 100644 index 000000000..b279d6d35 --- /dev/null +++ b/packages/vitnode/src/views/notifications/notification-item.tsx @@ -0,0 +1,172 @@ +import { cn } from "cn"; +import { ArchiveIcon, BellOffIcon, MailIcon, MailOpenIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { Avatar } from "@/components/avatar"; +import { DateFormat } from "@/components/date-format"; +import { Button } from "@/components/ui/button"; + +import type { NotificationItemView } from "./notifications-query"; + +export interface NotificationItemProps { + item: NotificationItemView; + onArchive?: (item: NotificationItemView) => void; + onOpen: (item: NotificationItemView, options: { newTab: boolean }) => void; + onToggleRead?: (item: NotificationItemView) => void; + variant?: "compact" | "full"; +} + +const isModifiedClick = (event: React.MouseEvent) => + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + event.button !== 0; + +const NotificationItemContent = ({ + item, + variant, +}: { + item: NotificationItemView; + variant: NonNullable; +}) => { + const t = useTranslations("core.global.notifications"); + const actor = item.actors.at(0); + + return ( + <> + + {actor && item.available ? ( + + ) : ( + + {item.available ? ( + + ) : ( + + )} + + )} + + + + {item.title} + + {item.body && variant === "full" ? ( + + {item.body} + + ) : null} + + + + + {item.unread ? ( + + + {t("unread")} + + ) : null} + + ); +}; + +const NotificationItemActions = ({ + item, + onArchive, + onToggleRead, +}: Pick) => { + const t = useTranslations("core.global.notifications"); + const toggleReadLabel = item.unread ? t("mark_read") : t("mark_unread"); + + return ( + + {onToggleRead ? ( + + ) : null} + {onArchive ? ( + + ) : null} + + ); +}; + +const itemClassName = + "hover:bg-accent focus-visible:ring-ring flex min-w-0 flex-1 items-start gap-3 rounded-md p-2 text-start outline-none focus-visible:ring-2"; + +export const NotificationItem = ({ + item, + onArchive, + onOpen, + onToggleRead, + variant = "full", +}: NotificationItemProps) => { + const linkTarget = item.available ? item.target : null; + const content = ; + + return ( +
  • + {linkTarget !== null ? ( + { + const newTab = isModifiedClick(event); + if (!newTab) event.preventDefault(); + onOpen(item, { newTab }); + }} + > + {content} + + ) : ( + + )} + + {variant === "full" && (onToggleRead || onArchive) ? ( + + ) : null} +
  • + ); +}; diff --git a/packages/vitnode/src/views/notifications/notification-state-store.test.ts b/packages/vitnode/src/views/notifications/notification-state-store.test.ts new file mode 100644 index 000000000..9012c7271 --- /dev/null +++ b/packages/vitnode/src/views/notifications/notification-state-store.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createNotificationStateStore } from "./notification-state-store"; + +describe("notification state store", () => { + it("only lets a newer revision replace the count", () => { + const store = createNotificationStateStore(); + + expect(store.apply(1, { revision: 5, unread: 3 })).toBe(true); + expect(store.apply(1, { revision: 7, unread: 4 })).toBe(true); + expect(store.apply(1, { revision: 6, unread: 9 })).toBe(false); + expect(store.apply(1, { revision: 7, unread: 9 })).toBe(false); + + expect(store.get()).toEqual({ revision: 7, unread: 4, userId: 1 }); + }); + + it("starts over for another user, whatever their revision", () => { + const store = createNotificationStateStore(); + store.apply(1, { revision: 50, unread: 3 }); + + expect(store.apply(2, { revision: 1, unread: 0 })).toBe(true); + expect(store.get()).toEqual({ revision: 1, unread: 0, userId: 2 }); + }); + + it("never shows a negative count and notifies subscribers once per change", () => { + const store = createNotificationStateStore(); + const listener = vi.fn(); + store.subscribe(listener); + + store.apply(1, { revision: 1, unread: -2 }); + store.apply(1, { revision: 1, unread: 5 }); + store.reset(); + store.reset(); + + expect(listener).toHaveBeenCalledTimes(2); + expect(store.get()).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/notifications/notification-state-store.ts b/packages/vitnode/src/views/notifications/notification-state-store.ts new file mode 100644 index 000000000..6fb90dcab --- /dev/null +++ b/packages/vitnode/src/views/notifications/notification-state-store.ts @@ -0,0 +1,64 @@ +import React from "react"; + +import type { NotificationState } from "@/lib/notifications/types"; + +export interface NotificationStateSnapshot extends NotificationState { + userId: number; +} + +type Listener = () => void; + +export const createNotificationStateStore = () => { + let snapshot: NotificationStateSnapshot | null = null; + const listeners = new Set(); + + const emit = () => listeners.forEach(listener => listener()); + + return { + apply: (userId: number, next: NotificationState): boolean => { + if (snapshot?.userId === userId && next.revision <= snapshot.revision) { + return false; + } + + snapshot = { + revision: next.revision, + unread: Math.max(0, next.unread), + userId, + }; + emit(); + + return true; + }, + get: () => snapshot, + reset: () => { + if (snapshot === null) return; + snapshot = null; + emit(); + }, + subscribe: (listener: Listener) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + }; +}; + +export const notificationStateStore = createNotificationStateStore(); + +const NO_SERVER_SNAPSHOT = () => null; + +export const useNotificationState = ( + userId: number, +): NotificationState | null => { + const snapshot = React.useSyncExternalStore( + notificationStateStore.subscribe, + notificationStateStore.get, + NO_SERVER_SNAPSHOT, + ); + + return snapshot?.userId === userId + ? { revision: snapshot.revision, unread: snapshot.unread } + : null; +}; diff --git a/packages/vitnode/src/views/notifications/notifications-actions.ts b/packages/vitnode/src/views/notifications/notifications-actions.ts new file mode 100644 index 000000000..12c61f9be --- /dev/null +++ b/packages/vitnode/src/views/notifications/notifications-actions.ts @@ -0,0 +1,103 @@ +import type { + NotificationEmailMode, + NotificationState, +} from "@/lib/notifications/types"; + +import { CONFIG_PLUGIN } from "@/config"; +import { fetcherClient } from "@/lib/fetcher-client"; + +import { NotificationsRequestError } from "./notifications-query"; + +const failed = (response: { status: number }): never => { + throw new NotificationsRequestError(response.status); +}; + +export const markNotificationReadInBrowser = async ({ + id, + throughSeq, +}: { + id: number; + throughSeq?: number; +}): Promise => { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: { throughSeq }, params: { id } }, + method: "post", + module: "notifications", + path: "/{id}/read", + }); + + return response.ok ? await response.json() : failed(response); +}; + +export const markNotificationUnreadInBrowser = async ({ + id, +}: { + id: number; +}): Promise => { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { id } }, + method: "post", + module: "notifications", + path: "/{id}/unread", + }); + + return response.ok ? await response.json() : failed(response); +}; + +export const archiveNotificationInBrowser = async ({ + id, +}: { + id: number; +}): Promise => { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { id } }, + method: "post", + module: "notifications", + path: "/{id}/archive", + }); + + return response.ok ? await response.json() : failed(response); +}; + +export const markAllNotificationsReadInBrowser = async ({ + category, +}: { + category?: string; +} = {}): Promise => { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: { category } }, + method: "post", + module: "notifications", + path: "/read-all", + }); + + return response.ok ? await response.json() : failed(response); +}; + +export interface UpdateNotificationPreferencesBody { + types?: Record< + string, + { + email?: NotificationEmailMode; + inApp?: boolean; + push?: boolean; + } + >; +} + +export const updateNotificationPreferencesInBrowser = async ( + body: UpdateNotificationPreferencesBody, +): Promise => { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body }, + method: "put", + module: "notifications", + path: "/preferences", + }); + if (!response.ok) failed(response); +}; diff --git a/packages/vitnode/src/views/notifications/notifications-bell-content.tsx b/packages/vitnode/src/views/notifications/notifications-bell-content.tsx new file mode 100644 index 000000000..1f0408909 --- /dev/null +++ b/packages/vitnode/src/views/notifications/notifications-bell-content.tsx @@ -0,0 +1,176 @@ +import { Link } from "@tanstack/react-router"; +import { BellIcon, CheckCheckIcon, Settings2Icon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { + Popover, + PopoverContent, + PopoverTitle, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; + +import type { NotificationItemProps } from "./notification-item"; +import type { NotificationItemView } from "./notifications-query"; + +import { NotificationItem } from "./notification-item"; + +const NOTIFICATIONS_HREF = "/notifications"; +export const NOTIFICATION_SETTINGS_HREF = "/settings/notifications"; + +const formatUnreadBadge = (count: number): null | string => { + if (count <= 0) return null; + + return count > 99 ? "99+" : String(count); +}; + +export interface NotificationsBellContentProps { + onMarkAllRead: () => void; + onOpenChange: (open: boolean) => void; + onOpenItem: NotificationItemProps["onOpen"]; + onRetry: () => void; + open: boolean; + recent: { + isError: boolean; + isPending: boolean; + items: NotificationItemView[] | undefined; + }; + unread: number; +} + +export const NotificationsBellContent = ({ + onMarkAllRead, + onOpenChange, + onOpenItem, + onRetry, + open, + recent, + unread, +}: NotificationsBellContentProps) => { + const t = useTranslations("core.global.notifications"); + const badge = formatUnreadBadge(unread); + + return ( + + 0 + ? t("bell_label_unread", { count: unread }) + : t("bell_label") + } + className="relative" + size="icon" + variant="ghost" + /> + } + > + + {badge ? ( + + {badge} + + ) : null} + + + +
    + {t("title")} + +
    + +
    + {recent.isPending ? ( +
    + {t("loading")} + {Array.from({ length: 3 }, (_, index) => ( +
    + +
    + + +
    +
    + ))} +
    + ) : recent.isError ? ( +
    +

    {t("load_error")}

    + +
    + ) : recent.items && recent.items.length > 0 ? ( +
      + {recent.items.map(item => ( + + ))} +
    + ) : ( + + + + + + {t("empty_title")} + {t("empty_desc")} + + + )} +
    + +
    + + +
    +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/notifications/notifications-query.ts b/packages/vitnode/src/views/notifications/notifications-query.ts new file mode 100644 index 000000000..a5ee8557b --- /dev/null +++ b/packages/vitnode/src/views/notifications/notifications-query.ts @@ -0,0 +1,201 @@ +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; + +import type { NotificationEmailMode } from "@/lib/notifications/types"; + +import { CONFIG_PLUGIN } from "@/config"; +import { + OPERATIONAL_STALE_TIME, + RECORD_STALE_TIME, +} from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; + +export interface NotificationActorView { + avatarColor: string; + avatarUrl: null | string; + id: number; + name: string; + nameCode: string; +} + +export interface NotificationItemView { + activitySeq: number; + actorCount: number; + actors: NotificationActorView[]; + available: boolean; + body: null | string; + category: string; + createdAt: Date | string; + eventCount: number; + id: number; + lastActivityAt: Date | string; + pluginId: string; + readAt: Date | null | string; + subject: null | { id: string; type: string }; + target: null | string; + title: string; + type: string; + unread: boolean; +} + +export interface NotificationsPage { + items: NotificationItemView[]; + nextCursor: null | string; +} + +export class NotificationsRequestError extends Error { + constructor(status: number) { + super(`The notifications API answered ${status}.`); + this.name = "NotificationsRequestError"; + this.status = status; + } + + readonly status: number; +} + +export const NOTIFICATIONS_IDENTITY_ROOT = ["notifications", "user"] as const; + +export const notificationsListRoot = (userId: number) => + [...NOTIFICATIONS_IDENTITY_ROOT, userId, "list"] as const; + +export interface NotificationsListFilter { + category?: string; + unread?: boolean; +} + +export const fetchNotificationsPage = async ({ + category, + cursor, + limit, + signal, + unread, +}: NotificationsListFilter & { + cursor?: null | string; + limit: number; + signal?: AbortSignal; +}): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { + query: { + category, + cursor: cursor ?? undefined, + limit, + unread: unread ? "true" : undefined, + }, + }, + method: "get", + module: "notifications", + options: { signal }, + path: "/", + }); + if (!response.ok) throw new NotificationsRequestError(response.status); + + return await response.json(); +}; + +const NOTIFICATIONS_PAGE_SIZE = 20; +const NOTIFICATIONS_RECENT_SIZE = 8; + +export const notificationsInfiniteQueryOptions = ({ + filter, + userId, +}: { + filter: NotificationsListFilter; + userId: number; +}) => + infiniteQueryOptions({ + getNextPageParam: (page: NotificationsPage) => page.nextCursor ?? undefined, + initialPageParam: null as null | string, + queryFn: async ({ pageParam, signal }) => + await fetchNotificationsPage({ + ...filter, + cursor: pageParam, + limit: NOTIFICATIONS_PAGE_SIZE, + signal, + }), + queryKey: [ + ...notificationsListRoot(userId), + "page", + filter.unread ? "unread" : "all", + filter.category ?? "", + ] as const, + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); + +export const recentNotificationsQueryOptions = ({ + userId, +}: { + userId: number; +}) => + queryOptions({ + queryFn: async ({ signal }) => + await fetchNotificationsPage({ + limit: NOTIFICATIONS_RECENT_SIZE, + signal, + }), + queryKey: [...notificationsListRoot(userId), "recent"] as const, + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); + +export const fetchNotificationState = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "notifications", + path: "/state", + }); + if (!response.ok) throw new NotificationsRequestError(response.status); + + return await response.json(); +}; + +export interface NotificationPreferenceTypeView { + category: string; + categoryLabel: string; + defaultEmail: NotificationEmailMode; + description: null | string; + emailModes: NotificationEmailMode[]; + id: string; + inAppAvailable: boolean; + label: string; + locked: boolean; + mandatory: boolean; + pluginId: string; + pushAvailable: boolean; + value: { + email: NotificationEmailMode; + inApp: boolean; + push: boolean; + }; +} + +export interface NotificationPreferencesView { + types: NotificationPreferenceTypeView[]; +} + +export const notificationPreferencesQueryKey = (userId: number) => + [...NOTIFICATIONS_IDENTITY_ROOT, userId, "preferences"] as const; + +export const notificationPreferencesQueryOptions = ({ + userId, +}: { + userId: number; +}) => + queryOptions({ + queryFn: async (): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "notifications", + path: "/preferences", + }); + if (!response.ok) throw new NotificationsRequestError(response.status); + + return await response.json(); + }, + queryKey: notificationPreferencesQueryKey(userId), + retry: false, + staleTime: RECORD_STALE_TIME, + }); diff --git a/packages/vitnode/src/views/notifications/settings/channels-card.tsx b/packages/vitnode/src/views/notifications/settings/channels-card.tsx new file mode 100644 index 000000000..f98dee570 --- /dev/null +++ b/packages/vitnode/src/views/notifications/settings/channels-card.tsx @@ -0,0 +1,217 @@ +import { + BellIcon, + CheckIcon, + type LucideIcon, + MailIcon, + SmartphoneIcon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { SettingsGroup } from "@/views/auth/settings/settings-group"; + +import type { EmailChannelState } from "./email-channel"; + +type PushPermission = "default" | "denied" | "granted" | "unsupported"; + +const readPushPermission = (): PushPermission => + "Notification" in window ? Notification.permission : "unsupported"; + +const permissionListeners = new Set<() => void>(); + +const subscribeToPermission = (listener: () => void) => { + permissionListeners.add(listener); + + return () => { + permissionListeners.delete(listener); + }; +}; + +const requestPushPermission = async (): Promise => { + const next = await Notification.requestPermission(); + permissionListeners.forEach(listener => { + listener(); + }); + + return next; +}; + +const ChannelRow = ({ + children, + description, + htmlFor, + Icon, + title, + trailing, +}: { + children?: React.ReactNode; + description: React.ReactNode; + htmlFor?: string; + Icon: LucideIcon; + title: string; + trailing?: React.ReactNode; +}) => { + const Text = htmlFor ? "label" : "div"; + + return ( +
  • + + + {title} + + {description} + + {children} + + {trailing ?
    {trailing}
    : null} +
  • + ); +}; + +const PushRow = () => { + const t = useTranslations("core.auth.settings.notifications.channels.push"); + const permission = React.useSyncExternalStore( + subscribeToPermission, + readPushPermission, + () => null, + ); + const [isAsking, setIsAsking] = React.useState(false); + const blocked = permission === "denied" || permission === "unsupported"; + + return ( + + + {t("enabled")} +
    + ) : permission === "default" ? ( + + ) : null + } + > + + {t("soon")} + + + ); +}; + +export const NotificationChannelsGroup = ({ + email, + emailState, + onEmailChange, +}: { + email: string; + emailState: EmailChannelState; + onEmailChange: (on: boolean) => void; +}) => { + const t = useTranslations("core.auth.settings.notifications"); + const emailSwitchId = React.useId(); + const emailSwitchRef = React.useRef(null); + const [confirmOpen, setConfirmOpen] = React.useState(false); + const address = () => ( + {email} + ); + + return ( + + + { + if (on) onEmailChange(true); + else setConfirmOpen(true); + }} + ref={emailSwitchRef} + /> + ) + } + /> + + + + + + {t("channels.email.stop_title")} + + + {t("channels.email.stop_desc")} + + + + + + + + + + ); +}; diff --git a/packages/vitnode/src/views/notifications/settings/email-channel.test.ts b/packages/vitnode/src/views/notifications/settings/email-channel.test.ts new file mode 100644 index 000000000..9252dc119 --- /dev/null +++ b/packages/vitnode/src/views/notifications/settings/email-channel.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; + +import { + emailChannelState, + emailOffPatch, + emailOnPatch, + emailSnapshot, +} from "./email-channel"; + +const type = ( + id: string, + overrides: Partial = {}, +): NotificationPreferenceTypeView => ({ + category: "content", + categoryLabel: "Content", + defaultEmail: "daily", + description: null, + emailModes: ["none", "immediate", "daily", "weekly"], + id, + inAppAvailable: true, + label: id, + locked: false, + mandatory: false, + pluginId: "@acme/forum", + pushAvailable: true, + value: { email: "daily", inApp: true, push: true }, + ...overrides, +}); + +const off = ( + id: string, + overrides: Partial = {}, +) => + type(id, { value: { email: "none", inApp: true, push: true }, ...overrides }); + +describe("emailChannelState", () => { + it("is on while any type the member can change still emails", () => { + expect(emailChannelState([off("a"), type("b")])).toBe("on"); + }); + + it("is off when every changeable type is set to no email", () => { + expect(emailChannelState([off("a"), off("b")])).toBe("off"); + }); + + it("ignores locked types, which keep emailing whatever the member does", () => { + expect(emailChannelState([off("a"), type("b", { locked: true })])).toBe( + "off", + ); + }); + + it("is unavailable when no type can email at all", () => { + expect(emailChannelState([type("a", { emailModes: [] })])).toBe( + "unavailable", + ); + }); +}); + +describe("turning email off and on again", () => { + it("brings back exactly what the member had before switching it off", () => { + const types = [ + type("a", { value: { email: "weekly", inApp: true, push: true } }), + off("b"), + type("c", { value: { email: "immediate", inApp: true, push: true } }), + ]; + const before = emailSnapshot(types); + + expect(emailOffPatch(types)).toEqual({ + a: { email: "none" }, + b: { email: "none" }, + c: { email: "none" }, + }); + expect( + emailOnPatch( + types.map(item => off(item.id)), + before, + ), + ).toEqual({ + a: { email: "weekly" }, + b: { email: "daily" }, + c: { email: "immediate" }, + }); + }); + + it("falls back to the installation default when nothing was saved", () => { + expect( + emailOnPatch([off("a", { defaultEmail: "weekly" }), off("b")], null), + ).toEqual({ a: { email: "weekly" }, b: { email: "daily" } }); + }); + + it("picks the daily digest when the default sends no email, so switching on always does something", () => { + expect(emailOnPatch([off("a", { defaultEmail: "none" })], null)).toEqual({ + a: { email: "daily" }, + }); + }); + + it("leaves locked types alone", () => { + expect(emailOnPatch([off("a"), off("b", { locked: true })], null)).toEqual({ + a: { email: "daily" }, + }); + }); +}); diff --git a/packages/vitnode/src/views/notifications/settings/email-channel.ts b/packages/vitnode/src/views/notifications/settings/email-channel.ts new file mode 100644 index 000000000..59eae8738 --- /dev/null +++ b/packages/vitnode/src/views/notifications/settings/email-channel.ts @@ -0,0 +1,54 @@ +import type { NotificationEmailMode } from "@/lib/notifications/types"; +import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; + +export type EmailChannelState = "off" | "on" | "unavailable"; + +const emailEditableTypes = (types: NotificationPreferenceTypeView[]) => + types.filter(type => !type.locked && type.emailModes.length > 0); + +export const emailChannelState = ( + types: NotificationPreferenceTypeView[], +): EmailChannelState => { + const editable = emailEditableTypes(types); + if (editable.length === 0) return "unavailable"; + + return editable.some(type => type.value.email !== "none") ? "on" : "off"; +}; + +const turnOnMode = ( + type: NotificationPreferenceTypeView, +): NotificationEmailMode => { + if (type.defaultEmail !== "none") return type.defaultEmail; + + return type.emailModes.includes("daily") ? "daily" : "immediate"; +}; + +export const emailOnPatch = ( + types: NotificationPreferenceTypeView[], + before: null | Record, +): Record => + Object.fromEntries( + emailEditableTypes(types).map(type => { + const previous = before?.[type.id]; + + return [ + type.id, + { + email: previous && previous !== "none" ? previous : turnOnMode(type), + }, + ]; + }), + ); + +export const emailOffPatch = (types: NotificationPreferenceTypeView[]) => + Object.fromEntries( + emailEditableTypes(types).map(type => [ + type.id, + { email: "none" as const }, + ]), + ); + +export const emailSnapshot = (types: NotificationPreferenceTypeView[]) => + Object.fromEntries( + emailEditableTypes(types).map(type => [type.id, type.value.email]), + ); diff --git a/packages/vitnode/src/views/notifications/settings/type-item.tsx b/packages/vitnode/src/views/notifications/settings/type-item.tsx new file mode 100644 index 000000000..14b5f395c --- /dev/null +++ b/packages/vitnode/src/views/notifications/settings/type-item.tsx @@ -0,0 +1,322 @@ +import { cn } from "cn"; +import { + BellIcon, + ChevronDownIcon, + LockIcon, + type LucideIcon, + MailIcon, + SmartphoneIcon, +} from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { NotificationEmailMode } from "@/lib/notifications/types"; +import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; + +import { RevealPanel } from "@/components/reveal-panel"; +import { + NativeSelect, + NativeSelectOption, +} from "@/components/ui/native-select"; +import { Switch } from "@/components/ui/switch"; + +export type NotificationTypePatch = Partial< + NotificationPreferenceTypeView["value"] +>; + +const ChannelChip = ({ + available, + Icon, + label, + on, +}: { + available: boolean; + Icon: LucideIcon; + label: string; + on: boolean; +}) => { + const t = useTranslations("core.auth.settings.notifications.types.summary"); + + return ( + + + + {label}: {!available ? t("unavailable") : on ? t("on") : t("off")} + + + ); +}; + +const ChannelSwitch = ({ + children, + description, + disabled, + Icon, + label, + onChange, + value, +}: { + children?: React.ReactNode; + description: string; + disabled: boolean; + Icon: LucideIcon; + label: string; + onChange: (value: boolean) => void; + value: boolean; +}) => { + const id = React.useId(); + + return ( +
    + +
    + {children} + +
    +
    + ); +}; + +const EMAIL_DESCRIPTION_KEY = { + daily: "types.email_daily", + immediate: "types.email_immediate", + none: "types.email_off", + weekly: "types.email_weekly", +} as const satisfies Record; + +const NotificationTypeSummary = ({ + emailOn, + emailShown, + isOpen, + onToggle, + panelId, + type, +}: { + emailOn: boolean; + emailShown: boolean; + isOpen: boolean; + onToggle: () => void; + panelId: string; + type: NotificationPreferenceTypeView; +}) => { + const t = useTranslations("core.auth.settings.notifications"); + + return ( +
    +
    +

    + {type.locked ? ( + + ) : null} + +

    + {type.description ? ( + + {type.description} + + ) : null} +
    + + + + + + +
    + ); +}; + +const EmailFrequencySelect = ({ + frequencies, + onSelect, + type, +}: { + frequencies: NotificationEmailMode[]; + onSelect: (mode: NotificationEmailMode) => void; + type: NotificationPreferenceTypeView; +}) => { + const t = useTranslations("core.auth.settings.notifications"); + const frequencyId = React.useId(); + + return ( + <> + + { + const next = frequencies.find(mode => mode === event.target.value); + if (next) onSelect(next); + }} + size="sm" + value={type.value.email} + > + {frequencies.map(mode => ( + + {t(`email_modes.${mode}`)} + + ))} + + + ); +}; + +export const NotificationTypeItem = ({ + isOpen, + onChange, + onToggle, + type, +}: { + isOpen: boolean; + onChange: (patch: NotificationTypePatch) => void; + onToggle: () => void; + type: NotificationPreferenceTypeView; +}) => { + const t = useTranslations("core.auth.settings.notifications"); + const panelId = React.useId(); + const emailOn = type.value.email !== "none"; + const emailShown = type.emailModes.length > 0 || type.locked; + const lastFrequencyRef = React.useRef( + emailOn ? type.value.email : "daily", + ); + const frequencies = type.emailModes.filter(mode => mode !== "none"); + const canPickFrequency = emailOn && !type.locked && frequencies.length > 1; + + return ( +
  • + + + +
    + {type.locked ? ( +

    + + {type.mandatory ? t("types.mandatory") : t("types.locked")} +

    + ) : null} + {type.inAppAvailable ? ( + { + onChange({ inApp }); + }} + value={type.value.inApp} + /> + ) : null} + {type.pushAvailable ? ( + { + onChange({ push }); + }} + value={type.value.push} + /> + ) : null} + {emailShown ? ( + { + if (!on && emailOn) lastFrequencyRef.current = type.value.email; + onChange({ email: on ? lastFrequencyRef.current : "none" }); + }} + value={emailOn} + > + {canPickFrequency ? ( + { + lastFrequencyRef.current = mode; + onChange({ email: mode }); + }} + type={type} + /> + ) : type.locked && emailOn ? ( + + {t(`email_modes.${type.value.email}`)} + + ) : null} + + ) : null} +
    +
    +
  • + ); +}; diff --git a/packages/vitnode/src/views/profile/images/user-image-dialog.tsx b/packages/vitnode/src/views/profile/images/user-image-dialog.tsx index a4295c3a2..e2bf655e4 100644 --- a/packages/vitnode/src/views/profile/images/user-image-dialog.tsx +++ b/packages/vitnode/src/views/profile/images/user-image-dialog.tsx @@ -11,6 +11,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton"; +import { TooltipWithContent } from "@/components/ui/tooltip"; import type { UserImageDialogContentProps, @@ -52,18 +53,20 @@ export const UserImageDialog = ({ return ( - - } - > - - + + + } + > + + + diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index a39624f92..5a929c848 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -3,6 +3,7 @@ import type { IRateLimiterOptions } from "rate-limiter-flexible"; import type { CacheConfig } from "./api/lib/cache"; import type { CronAdapter } from "./api/lib/cron"; +import type { NotificationWorkerSettings } from "./api/lib/notifications/preferences"; import type { PasskeysConfig } from "./api/lib/passkey-config"; import type { BuildPluginApiReturn } from "./api/lib/plugin"; import type { AIConfig } from "./api/models/ai"; @@ -116,6 +117,7 @@ export interface VitNodeApiConfig { */ maxBodySize?: number; metadata: VitNodeMetadata; + notifications?: Partial; plugins: BuildPluginApiReturn[]; rateLimiter?: Omit; diff --git a/packages/vitnode/src/ws/notifications.ts b/packages/vitnode/src/ws/notifications.ts index 49ad1b167..58e140477 100644 --- a/packages/vitnode/src/ws/notifications.ts +++ b/packages/vitnode/src/ws/notifications.ts @@ -1,3 +1,5 @@ +import type { NotificationStateMessage } from "@/lib/notifications/types"; + import { CONFIG_PLUGIN } from "@/config"; import { createWebSocketChannel } from "./types"; @@ -19,3 +21,12 @@ export const notificationsChannel = createWebSocketChannel< module: "notifications", pluginId: CONFIG_PLUGIN.pluginId, }); + +export const notificationsStateChannel = createWebSocketChannel< + never, + NotificationStateMessage +>({ + id: "state", + module: "notifications", + pluginId: CONFIG_PLUGIN.pluginId, +}); diff --git a/plugins/blog/package.json b/plugins/blog/package.json index fe6f8ac8f..e4721a9ea 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -38,6 +38,7 @@ "dependencies": { "@hono/zod-openapi": "^1.6.3", "@tanstack/react-form": "^1.33.5", + "@tanstack/react-router": "^1.170.41", "@vitnode/core": "workspace:*", "ai": "^7.0.124", "drizzle-kit": "1.0.0-rc.4", diff --git a/plugins/blog/src/admin/content.tsx b/plugins/blog/src/admin/content.tsx index 11c134d74..335046e4e 100644 --- a/plugins/blog/src/admin/content.tsx +++ b/plugins/blog/src/admin/content.tsx @@ -4,6 +4,12 @@ import { contentTypeAdmin } from "@vitnode/core/lib/plugin"; import { CONFIG_PLUGIN } from "@/const"; import { BlogArticleEditorField } from "@/views/admin/article/editor-field"; +import { + ArticleCoverAltField, + ArticleExcerptField, + ArticleSlugField, + ArticleTitleField, +} from "@/views/admin/article/editor/fields"; import { BlogArticleFormLayout } from "@/views/admin/article/form-layout"; import { BlogCategoryColorCell } from "@/views/admin/category/color-cell"; import { BlogCategoryColorField } from "@/views/admin/category/color-field"; @@ -18,6 +24,10 @@ export const adminContent = { fields: { // The Tiptap editor, inside the same AutoForm as everything else. content: { component: BlogArticleEditorField, skeleton: "editor" }, + title: { component: ArticleTitleField }, + friendlyUrl: { component: ArticleSlugField }, + excerpt: { component: ArticleExcerptField, skeleton: "textarea" }, + coverImageAlt: { component: ArticleCoverAltField }, }, forms: { // One layout for both actions - they are the same screen, and writing diff --git a/plugins/blog/src/api/lib/ai-writing.ts b/plugins/blog/src/api/lib/ai-writing.ts new file mode 100644 index 000000000..55e43f297 --- /dev/null +++ b/plugins/blog/src/api/lib/ai-writing.ts @@ -0,0 +1,82 @@ +import type { LanguageModel } from "ai"; + +import { stripHtml } from "@vitnode/core/lib/strip-html"; +import { generateText } from "ai"; + +export type AiTextFormat = "html" | "text"; + +export const EXCERPT_RECOMMENDED_LENGTH = 160; + +const languageName = (locale: string) => { + try { + return ( + new Intl.DisplayNames(["en"], { type: "language" }).of(locale) ?? locale + ); + } catch { + return locale; + } +}; + +const unquote = (text: string) => + text + .trim() + .replace(/^["“„'](.*)["”'"]$/su, "$1") + .trim(); + +export const translateWithAi = async ({ + format, + from, + model, + text, + to, +}: { + format: AiTextFormat; + from: string; + model: LanguageModel; + text: string; + to: string; +}): Promise => { + const { text: output } = await generateText({ + model, + temperature: 0, + system: [ + "You translate fields of a blog article for a content management system.", + `Translate from ${languageName(from)} into ${languageName(to)}.`, + format === "html" + ? "The input is HTML. Keep every tag, attribute and the document structure exactly as they are, and translate only the human-readable text." + : "The input is plain text. Answer with plain text only.", + "Keep product names, code, URLs and numbers unchanged.", + "Answer with the translation only, without quotes, notes or explanations.", + ].join("\n"), + prompt: text, + }); + + return format === "html" ? output.trim() : unquote(output); +}; + +export const writeExcerptWithAi = async ({ + content, + locale, + model, + title, +}: { + content: string; + locale: string; + model: LanguageModel; + title: string; +}): Promise => { + const { text } = await generateText({ + model, + temperature: 0.3, + system: [ + "You write the excerpt of a blog article: one or two sentences shown on the blog list and as the search result description.", + `Write it in ${languageName(locale)}.`, + `Keep it under ${EXCERPT_RECOMMENDED_LENGTH} characters.`, + "Make it specific to the article, never generic, and do not start with the title.", + "Answer with the excerpt only, without quotes, notes or explanations.", + ].join("\n"), + prompt: `Title: ${title}\n\nArticle:\n${stripHtml(content).slice(0, 12_000)}`, + }); + + return unquote(text); +}; diff --git a/plugins/blog/src/api/modules/admin/admin.module.ts b/plugins/blog/src/api/modules/admin/admin.module.ts index 79b10572c..5b41455c8 100644 --- a/plugins/blog/src/api/modules/admin/admin.module.ts +++ b/plugins/blog/src/api/modules/admin/admin.module.ts @@ -6,11 +6,14 @@ import { CONFIG_PLUGIN } from "@/const"; import { categoryContent } from "@/database/categories"; import { postContent } from "@/database/posts"; +import { aiAdminModule } from "./ai/ai.admin.module"; + export const adminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "admin", routes: [], modules: [ + aiAdminModule, buildContentAdminModule({ pluginId: CONFIG_PLUGIN.pluginId, contentTypes: [categoryContent, postContent], diff --git a/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts b/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts new file mode 100644 index 000000000..c998886f6 --- /dev/null +++ b/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts @@ -0,0 +1,199 @@ +// @vitest-environment node +import type { CacheClient } from "@vitnode/core/api/lib/cache"; +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { CacheModel } from "@vitnode/core/api/lib/cache"; +import { writeStaffPermissions } from "@vitnode/core/api/lib/staff-permission-cache"; +import { MockLanguageModelV4 } from "ai/test"; +import { describe, expect, it } from "vitest"; + +import { excerptAiAdminRoute } from "./routes/excerpt.route"; +import { translateAiAdminRoute } from "./routes/translate.route"; + +const EDITOR_ID = 7; + +const createCache = () => { + const store = new Map(); + const client = { + del: async (keys: string | string[]) => + Promise.resolve( + (Array.isArray(keys) ? keys : [keys]).filter(key => store.delete(key)) + .length, + ), + exists: async (key: string) => Promise.resolve(store.has(key) ? 1 : 0), + get: async (key: string) => Promise.resolve(store.get(key) ?? null), + ping: async () => Promise.resolve("PONG"), + set: async (key: string, value: string) => { + store.set(key, value); + + return Promise.resolve("OK"); + }, + } as unknown as CacheClient; + + return new CacheModel(client, { get: () => undefined } as unknown as Context); +}; + +const grant = async (cache: CacheModel, canEdit: boolean) => { + await writeStaffPermissions( + { + get: (key: string) => (key === "cache" ? cache : undefined), + } as unknown as Context, + { type: "admin", userId: EDITOR_ID }, + { + permissions: [ + { module: "posts", permission: "can_view", plugin: "@vitnode/blog" }, + ...(canEdit + ? [ + { + module: "posts", + permission: "can_edit", + plugin: "@vitnode/blog", + }, + ] + : []), + ], + root: false, + staff: true, + }, + ); +}; + +const modelAnswering = (text: string) => + new MockLanguageModelV4({ + doGenerate: { + content: [{ type: "text", text }], + finishReason: { raw: "stop", unified: "stop" }, + usage: { + inputTokens: { cacheRead: 0, cacheWrite: 0, noCache: 10, total: 10 }, + outputTokens: { reasoning: 0, text: 5, total: 5 }, + }, + warnings: [], + }, + }); + +const createApp = async ({ + canEdit = true, + configured = true, + model = modelAnswering("Cześć"), +}: { + canEdit?: boolean; + configured?: boolean; + model?: MockLanguageModelV4; +} = {}) => { + const cache = createCache(); + await grant(cache, canEdit); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin" as never, { user: { id: EDITOR_ID } } as never); + c.set("cache" as never, cache as never); + c.set( + "core" as never, + { ai: configured ? { models: [{ id: "default" }] } : undefined } as never, + ); + c.set("ai" as never, { model: () => model } as never); + await next(); + }); + app.openapi(translateAiAdminRoute.route, translateAiAdminRoute.handler); + app.openapi(excerptAiAdminRoute.route, excerptAiAdminRoute.handler); + + return { app, model }; +}; + +const post = async (app: OpenAPIHono, path: string, body: unknown) => + await app.request(path, { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + +const promptOf = ( + call: MockLanguageModelV4["doGenerateCalls"][number] | undefined, +) => JSON.stringify(call?.prompt ?? []); + +describe("blog AI admin routes", () => { + it("translates a field into the target language", async () => { + const { app, model } = await createApp({ + model: modelAnswering("“Cześć, świecie”"), + }); + + const response = await post(app, "/translate", { + format: "text", + from: "en", + text: "Hello, world", + to: "pl", + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ text: "Cześć, świecie" }); + expect(promptOf(model.doGenerateCalls[0])).toContain("into Polish"); + expect(promptOf(model.doGenerateCalls[0])).toContain("Hello, world"); + }); + + it("keeps HTML intact when translating content", async () => { + const { app, model } = await createApp({ + model: modelAnswering("

    Cześć

    \n"), + }); + + const response = await post(app, "/translate", { + format: "html", + from: "en", + text: "

    Hello

    ", + to: "pl", + }); + + expect(await response.json()).toEqual({ text: "

    Cześć

    " }); + expect(promptOf(model.doGenerateCalls[0])).toContain("Keep every tag"); + }); + + it("writes an excerpt from the article text", async () => { + const { app, model } = await createApp({ + model: modelAnswering( + "One definition per content type and faster lists.", + ), + }); + + const response = await post(app, "/excerpt", { + content: "

    Why

    We rebuilt the AdminCP.

    ", + locale: "en", + title: "VitNode 2.0", + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + text: "One definition per content type and faster lists.", + }); + expect(promptOf(model.doGenerateCalls[0])).toContain( + "We rebuilt the AdminCP.", + ); + expect(promptOf(model.doGenerateCalls[0])).not.toContain("

    "); + }); + + it("refuses staff who cannot edit articles", async () => { + const { app, model } = await createApp({ canEdit: false }); + + const response = await post(app, "/translate", { + format: "text", + from: "en", + text: "Hello", + to: "pl", + }); + + expect(response.status).toBe(403); + expect(model.doGenerateCalls).toHaveLength(0); + }); + + it("answers 400 when no AI model is configured", async () => { + const { app, model } = await createApp({ configured: false }); + + const response = await post(app, "/excerpt", { + content: "

    Body

    ", + locale: "en", + title: "Title", + }); + + expect(response.status).toBe(400); + expect(model.doGenerateCalls).toHaveLength(0); + }); +}); diff --git a/plugins/blog/src/api/modules/admin/ai/ai.admin.module.ts b/plugins/blog/src/api/modules/admin/ai/ai.admin.module.ts new file mode 100644 index 000000000..8ae1f911f --- /dev/null +++ b/plugins/blog/src/api/modules/admin/ai/ai.admin.module.ts @@ -0,0 +1,12 @@ +import { buildModule } from "@vitnode/core/api/lib/module"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { excerptAiAdminRoute } from "./routes/excerpt.route"; +import { translateAiAdminRoute } from "./routes/translate.route"; + +export const aiAdminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "ai", + routes: [translateAiAdminRoute, excerptAiAdminRoute], +}); diff --git a/plugins/blog/src/api/modules/admin/ai/routes/excerpt.route.ts b/plugins/blog/src/api/modules/admin/ai/routes/excerpt.route.ts new file mode 100644 index 000000000..bcd4505e2 --- /dev/null +++ b/plugins/blog/src/api/modules/admin/ai/routes/excerpt.route.ts @@ -0,0 +1,53 @@ +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { writeExcerptWithAi } from "@/api/lib/ai-writing"; +import { CONFIG_PLUGIN } from "@/const"; + +export const zodExcerptAiSchema = z.object({ + content: z.string().trim().min(1).max(200_000), + locale: z.string().min(2).max(16), + title: z.string().trim().min(1).max(255), +}); + +export const excerptAiAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "posts", permission: "can_edit" }, + route: { + method: "post", + description: + "Write a short excerpt for an article with the configured AI model.", + path: "/excerpt", + request: { + body: { + required: true, + content: { "application/json": { schema: zodExcerptAiSchema } }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ text: z.string() }) }, + }, + description: "The written excerpt", + }, + 400: { description: "No AI model is configured" }, + }, + }, + handler: async c => { + if (!c.get("core").ai?.models.length) { + throw new HTTPException(400, { message: "No AI models configured" }); + } + + const { content, locale, title } = c.req.valid("json"); + const excerpt = await writeExcerptWithAi({ + content, + locale, + model: c.get("ai").model(), + title, + }); + + return c.json({ text: excerpt }, 200); + }, +}); diff --git a/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts b/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts new file mode 100644 index 000000000..2ce6080ec --- /dev/null +++ b/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts @@ -0,0 +1,57 @@ +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { translateWithAi } from "@/api/lib/ai-writing"; +import { CONFIG_PLUGIN } from "@/const"; + +const zodLocale = z.string().min(2).max(16); + +export const zodTranslateAiSchema = z.object({ + format: z.enum(["html", "text"]), + from: zodLocale, + text: z.string().trim().min(1).max(100_000), + to: zodLocale, +}); + +export const translateAiAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "posts", permission: "can_edit" }, + route: { + method: "post", + description: + "Translate one field of an article into another language with the configured AI model.", + path: "/translate", + request: { + body: { + required: true, + content: { "application/json": { schema: zodTranslateAiSchema } }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ text: z.string() }) }, + }, + description: "The translated text", + }, + 400: { description: "No AI model is configured" }, + }, + }, + handler: async c => { + if (!c.get("core").ai?.models.length) { + throw new HTTPException(400, { message: "No AI models configured" }); + } + + const { format, from, text, to } = c.req.valid("json"); + const translated = await translateWithAi({ + format, + from, + model: c.get("ai").model(), + text, + to, + }); + + return c.json({ text: translated }, 200); + }, +}); diff --git a/plugins/blog/src/content/post.ts b/plugins/blog/src/content/post.ts index ea1466f04..05a84c7d3 100644 --- a/plugins/blog/src/content/post.ts +++ b/plugins/blog/src/content/post.ts @@ -51,6 +51,11 @@ export const blogPostContentType = defineContentType({ source: "title", }), content: field.textarea({ localized: true, required: true }), + excerpt: field.textarea({ + localized: true, + nullable: true, + maxLength: 300, + }), coverImage: field.file({ maxBytes: 5 * 1024 * 1024, @@ -76,6 +81,7 @@ export const blogPostContentType = defineContentType({ "title", "friendlyUrl", "content", + "excerpt", "categoryId", // A file crosses the public boundary as the normalised descriptor - `{ id, // name, url, mimeType, size, width, height }` - and never as the @@ -106,7 +112,11 @@ export const blogPostContentType = defineContentType({ delivery: { enabled: true, redirects: { enabled: true }, - seo: { titleField: "title", descriptionField: "content" }, + seo: { + titleField: "title", + descriptionField: "excerpt", + fallbackDescriptionField: "content", + }, sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, hreflang: { xDefault: "defaultLocale" }, }, diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index 917475a3d..d2fcd3922 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -16,7 +16,8 @@ "coverImageAlt": "Cover image alt text", "status": "Status", "publishedAt": "Published", - "updatedAt": "Updated" + "updatedAt": "Updated", + "excerpt": "Excerpt" } }, "category": { @@ -35,13 +36,86 @@ "content": { "label": "Content" }, - "form": { - "publish": "Publish", - "cover": { - "title": "Cover image" + "editor": { + "heading": "Article editor", + "title": { + "placeholder": "Article title" + }, + "excerpt": { + "placeholder": "One or two sentences that make someone click.", + "hint": "Shown on the blog list and as the search result description." + }, + "alt": { + "placeholder": "What the image shows", + "hint": "Read aloud by screen readers instead of the image." + }, + "counter": { + "title": "Search results show about {max} characters.", + "excerpt": "Search results show about {max} characters.", + "coverImageAlt": "Screen readers read about {max} characters before pausing." + }, + "translate": { + "button": "Translate", + "menu": "Translate from {language} into", + "complete": "Complete", + "missing": "{count, plural, one {# field missing} other {# fields missing}}", + "outdated": "Outdated", + "active": "Translating into {language}", + "exit": "Stop translating", + "source": "{language} · source", + "target": "{language}", + "field": "Translate", + "field_again": "Translate again", + "slug": "Generate from title", + "missing_fields": "Translate missing fields", + "outdated_banner": "The {language} version changed after this translation was last saved.", + "update_all": "Update all with AI", + "dismiss": "Dismiss", + "empty_source": "No {language} text yet.", + "status": { + "done": "Translated", + "missing": "Missing", + "unavailable": "Nothing to translate" + }, + "field_into": "Translate into {language}" + }, + "ai": { + "write_excerpt": "Write with AI", + "translating": "Translating…", + "writing": "Writing…", + "done": "Done. Read it through before you save.", + "error": "The AI request did not finish. Try again in a moment.", + "not_configured": "No AI model is configured for this site." }, - "settings": { - "title": "Article settings" + "publish": { + "open": "Ready to publish: {done} of {total} checks done", + "title": "Ready to publish", + "checks": { + "title": "Title fits search results", + "title_detail": "{languages}: longer than {max} characters.", + "excerpt": "Excerpt in every language", + "excerpt_detail": "Missing in {languages}.", + "cover": "Cover image", + "cover_detail": "Shown on the blog list and when the article is shared.", + "alt": "Cover alt text in every language", + "alt_detail": "Missing in {languages}.", + "translations": "Translations complete", + "translations_detail": "{language}: {count, plural, one {# field} other {# fields}} missing", + "outdated": "Translations up to date", + "outdated_detail": "{languages} changed less recently than {source}.", + "open": "Open {language}", + "write": "Write with AI" + }, + "previews": { + "title": "Previews", + "language": "Preview language", + "search": "Search result", + "social": "Social card", + "fallback": "{language} readers see the {source} text for missing fields.", + "no_cover": "Add a cover image to see the social card." + }, + "details": "Details", + "cover": "Cover image" } } }, diff --git a/plugins/blog/src/views/admin/article/editor-field.tsx b/plugins/blog/src/views/admin/article/editor-field.tsx index 1fac7372b..89884e44d 100644 --- a/plugins/blog/src/views/admin/article/editor-field.tsx +++ b/plugins/blog/src/views/admin/article/editor-field.tsx @@ -16,7 +16,12 @@ export const BlogArticleEditorField = (props: ItemAutoFormComponentProps) => { return ( }> - + ); }; diff --git a/plugins/blog/src/views/admin/article/editor/ai.ts b/plugins/blog/src/views/admin/article/editor/ai.ts new file mode 100644 index 000000000..96a2f7473 --- /dev/null +++ b/plugins/blog/src/views/admin/article/editor/ai.ts @@ -0,0 +1,52 @@ +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { CONFIG_PLUGIN } from "@/const"; + +export class ArticleAiError extends Error { + constructor(status: number) { + super(`The AI request failed with status ${status}.`); + this.name = "ArticleAiError"; + this.status = status; + } + + readonly status: number; +} + +export const translateArticleText = async (body: { + format: "html" | "text"; + from: string; + text: string; + to: string; +}): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/ai", + path: "/translate", + args: { body }, + }); + if (response.status !== 200) throw new ArticleAiError(response.status); + + const { text } = await response.json(); + + return text; +}; + +export const writeArticleExcerpt = async (body: { + content: string; + locale: string; + title: string; +}): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/ai", + path: "/excerpt", + args: { body }, + }); + if (response.status !== 200) throw new ArticleAiError(response.status); + + const { text } = await response.json(); + + return text; +}; diff --git a/plugins/blog/src/views/admin/article/editor/article-editor.tsx b/plugins/blog/src/views/admin/article/editor/article-editor.tsx new file mode 100644 index 000000000..e00cf6d34 --- /dev/null +++ b/plugins/blog/src/views/admin/article/editor/article-editor.tsx @@ -0,0 +1,547 @@ +import { Link } from "@tanstack/react-router"; +import { MultiLangLanguageContext } from "@vitnode/core/components/form/fields/multi-lang-language"; +import { useLanguages } from "@vitnode/core/components/languages-provider"; +import { Button } from "@vitnode/core/components/ui/button"; +import { EditorContent } from "@vitnode/core/components/ui/editor-content"; +import { + ContentFormActions, + ContentFormField, + ContentFormStatusSwitch, + useContentForm, + useContentFormValues, + useSetContentFormValue, +} from "@vitnode/core/content/admin-form"; +import { slugify } from "@vitnode/core/content/slug"; +import { + getLangValue, + upsertLangValue, +} from "@vitnode/core/lib/helpers/multi-lang"; +import { cn } from "cn"; +import { ArrowLeftIcon, ListChecksIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { translateArticleText, writeArticleExcerpt } from "./ai"; +import { + type ArticleFieldActions, + ArticleFieldActionsContext, +} from "./field-actions"; +import { type CheckAction, Previews, ReadinessList } from "./publish-panel"; +import { + type ArticleCheck, + articleChecks, + type ArticleValues, + outdatedLocales, + REQUIRED_TRANSLATED_FIELDS, + type TranslatedField, + translatedFieldStatus, +} from "./readiness"; +import { + ActiveTranslation, + AiButton, + OutdatedBanner, + PairRow, + TranslateMenu, +} from "./translate"; +import { useArticleAi } from "./use-article-ai"; + +const AI_TRANSLATED_FIELDS = [ + "title", + "content", + "excerpt", + "coverImageAlt", +] as const satisfies readonly TranslatedField[]; + +export const ArticleEditor = () => { + const t = useTranslations("@vitnode/blog.admin.article.editor"); + const { + defaultLocale, + files, + header, + markHeaderRendered, + translations = [], + } = useContentForm(); + const languages = useLanguages(); + const source = defaultLocale ?? languages[0]?.code ?? "en"; + const locales = languages.map(language => language.code); + const values = useContentFormValues() as ArticleValues; + const valuesRef = React.useRef(values); + React.useEffect(() => { + valuesRef.current = values; + }); + const setFormValue = useSetContentFormValue(); + const ai = useArticleAi(); + const [target, setTarget] = React.useState(null); + const [contentRevision, setContentRevision] = React.useState(0); + const [dismissed, setDismissed] = React.useState([]); + const panelRef = React.useRef(null); + + markHeaderRendered?.(); + + const languageName = (code: string) => + languages.find(language => language.code === code)?.name ?? code; + const sourceName = languageName(source); + const outdated = outdatedLocales(translations, source); + const checks = articleChecks({ locales, outdated, source, values }); + const ready = checks.filter(check => check.ok).length; + + const setLangValue = ( + field: TranslatedField, + locale: string, + text: string, + ) => { + setFormValue( + field, + upsertLangValue(valuesRef.current[field], locale, text), + ); + if (field === "content") setContentRevision(revision => revision + 1); + }; + + const statusOf = (field: TranslatedField, locale: string) => + translatedFieldStatus(values, field, { source, target: locale }); + + const translateField = async (field: TranslatedField, to: string) => { + if (field === "friendlyUrl") { + const title = + getLangValue(valuesRef.current.title, to) || + getLangValue(valuesRef.current.title, source); + setLangValue("friendlyUrl", to, slugify(title)); + + return; + } + + await ai.run( + `${field}:${to}`, + async () => { + setLangValue( + field, + to, + await translateArticleText({ + format: field === "content" ? "html" : "text", + from: source, + text: getLangValue(valuesRef.current[field], source), + to, + }), + ); + }, + t("translate.active", { language: languageName(to) }), + ); + }; + + const translateFields = async (to: string, fields: TranslatedField[]) => { + await Promise.all( + fields + .filter(field => field !== "friendlyUrl") + .map(async field => { + await translateField(field, to); + }), + ); + if (fields.includes("friendlyUrl")) await translateField("friendlyUrl", to); + }; + + const missingFields = (to: string) => + [...AI_TRANSLATED_FIELDS, "friendlyUrl" as const].filter( + field => statusOf(field, to) === "missing", + ); + + const writeExcerpt = async (locale: string) => { + const current = valuesRef.current; + if (locale !== source && getLangValue(current.excerpt, source).trim()) { + await translateField("excerpt", locale); + + return; + } + + await ai.run( + `excerpt:${locale}`, + async () => { + setLangValue( + "excerpt", + locale, + await writeArticleExcerpt({ + content: + getLangValue(current.content, locale) || + getLangValue(current.content, source), + locale, + title: + getLangValue(current.title, locale) || + getLangValue(current.title, source), + }), + ); + }, + t("ai.write_excerpt"), + ); + }; + + const openLanguage = (code: string) => { + setTarget(code); + window.scrollTo({ top: 0 }); + }; + + const actionsFor = (check: ArticleCheck): CheckAction[] => { + switch (check.id) { + case "alt": + return ai.available + ? check.missing + .filter(code => code !== source) + .map(code => ({ + ai: true, + label: t("translate.field_into", { + language: languageName(code), + }), + run: () => { + void translateField("coverImageAlt", code); + }, + })) + : []; + case "excerpt": + return ai.available + ? [ + { + ai: true, + label: t("publish.checks.write"), + run: () => { + for (const code of check.missing) void writeExcerpt(code); + }, + }, + ] + : []; + case "outdated": + return check.outdated.map(code => ({ + label: t("publish.checks.open", { language: languageName(code) }), + run: () => { + openLanguage(code); + }, + })); + case "translations": + return Object.keys(check.missing).map(code => ({ + label: t("publish.checks.open", { language: languageName(code) }), + run: () => { + openLanguage(code); + }, + })); + default: + return []; + } + }; + + const fieldLocale = target ?? source; + const excerptPending = ai.isPending(`excerpt:${fieldLocale}`); + const fieldActions: ArticleFieldActions = ai.available + ? { + excerpt: ( + { + void writeExcerpt(fieldLocale); + }} + pending={excerptPending} + pendingLabel={t("ai.writing")} + /> + ), + ...(target && getLangValue(values.coverImageAlt, source).trim() + ? { + coverImageAlt: ( + { + void translateField("coverImageAlt", target); + }} + pending={ai.isPending(`coverImageAlt:${target}`)} + pendingLabel={t("ai.translating")} + /> + ), + } + : {}), + } + : {}; + + const fieldAction = (field: TranslatedField, to: string) => { + if (field !== "friendlyUrl" && !ai.available) return null; + if (statusOf(field, to) === "unavailable") return null; + + return ( + { + void translateField(field, to); + }} + pending={ai.isPending(`${field}:${to}`)} + pendingLabel={t("ai.translating")} + /> + ); + }; + + const translationLanguages = languages + .filter(language => language.code !== source) + .map(language => ({ + code: language.code, + missing: REQUIRED_TRANSLATED_FIELDS.filter( + field => statusOf(field, language.code) === "missing", + ).length, + name: language.name, + outdated: outdated.includes(language.code), + })); + + const sharedFields = ( +
    +
    + +
    +
    + +
    +
    + ); + + const contentField = ( + + + + ); + + return ( + +
    +
    + {header ? ( + + ) : null} +

    {header?.title ?? t("heading")}

    +
    + {translationLanguages.length > 0 ? ( + target ? ( + { + setTarget(null); + }} + sourceName={sourceName} + targetName={languageName(target)} + /> + ) : ( + + ) + ) : null} + + + +
    + +
    +
    + {target ? ( + <> + {outdated.includes(target) && !dismissed.includes(target) ? ( + { + void translateFields(target, [ + ...AI_TRANSLATED_FIELDS, + ]); + }} + pending={AI_TRANSLATED_FIELDS.some(field => + ai.isPending(`${field}:${target}`), + )} + pendingLabel={t("ai.translating")} + /> + ) : null + } + onDismiss={() => { + setDismissed(current => [...current, target]); + }} + sourceName={sourceName} + /> + ) : null} + + {ai.available && missingFields(target).length > 0 ? ( +
    + { + void translateFields(target, missingFields(target)); + }} + pending={AI_TRANSLATED_FIELDS.some(field => + ai.isPending(`${field}:${target}`), + )} + pendingLabel={t("ai.translating")} + /> +
    + ) : null} + + + + {getLangValue(values.title, source) || + t("translate.empty_source", { language: sourceName })} +

    + } + sourceName={sourceName} + status={statusOf("title", target)} + targetName={languageName(target)} + > + +
    + + /blog/{getLangValue(values.friendlyUrl, source)} +

    + } + sourceName={sourceName} + status={statusOf("friendlyUrl", target)} + targetName={languageName(target)} + > + +
    +
    + + {sharedFields} + + + + +
    + } + sourceName={sourceName} + status={statusOf("content", target)} + targetName={languageName(target)} + > + {contentField} + + + + ) : ( + +
    + + +
    + {sharedFields} + {contentField} +
    + )} +
    + + +
    +
    + + ); +}; diff --git a/plugins/blog/src/views/admin/article/editor/field-actions.ts b/plugins/blog/src/views/admin/article/editor/field-actions.ts new file mode 100644 index 000000000..9f33177f9 --- /dev/null +++ b/plugins/blog/src/views/admin/article/editor/field-actions.ts @@ -0,0 +1,9 @@ +import React from "react"; + +export type ArticleFieldActions = Partial>; + +export const ArticleFieldActionsContext = + React.createContext({}); + +export const useArticleFieldAction = (name: string): React.ReactNode => + React.use(ArticleFieldActionsContext)[name]; diff --git a/plugins/blog/src/views/admin/article/editor/fields.tsx b/plugins/blog/src/views/admin/article/editor/fields.tsx new file mode 100644 index 000000000..3bcde242b --- /dev/null +++ b/plugins/blog/src/views/admin/article/editor/fields.tsx @@ -0,0 +1,183 @@ +import type { ItemAutoFormComponentProps } from "@vitnode/core/components/form/auto-form"; + +import { AutoFormLabel } from "@vitnode/core/components/form/common/label"; +import { + type MultiLangFieldProps, + useMultiLangField, +} from "@vitnode/core/components/form/fields/multi-lang"; +import { FormControl, FormMessage } from "@vitnode/core/components/ui/form"; +import { cn } from "cn"; +import { useTranslations } from "use-intl"; + +import { useArticleFieldAction } from "./field-actions"; +import { RECOMMENDED_LENGTH } from "./readiness"; + +const fieldClassName = + "bg-background border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 w-full min-w-0 rounded-md border px-2.5 text-base outline-none transition-[border-color,box-shadow] duration-150 ease-out focus-visible:ring-3 motion-reduce:transition-none md:text-sm"; + +export const CharacterCount = ({ + field, + value, +}: { + field: keyof typeof RECOMMENDED_LENGTH; + value: string; +}) => { + const t = useTranslations("@vitnode/blog.admin.article.editor.counter"); + const max = RECOMMENDED_LENGTH[field]; + const over = value.length > max; + + return ( + + {value.length} / {max} + . {t(field, { max })} + + ); +}; + +export const ArticleTitleField = ({ field }: ItemAutoFormComponentProps) => { + const t = useTranslations("@vitnode/blog"); + const { currentValue, selected, setValue } = useMultiLangField( + field as MultiLangFieldProps["field"], + ); + + return ( + <> + + {t("content.post.fields.title")} + + +