From 1892a2c1051e274ef2f0812f06113aea3fe0cb6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 08:56:07 +0000 Subject: [PATCH 1/6] feat(notifications): Notifications Center in core A shared, plugin-extensible Notifications Center built on the existing database queue, WebSocket transport and email adapters. - Plugins register typed, versioned notification types and followable subjects (`notificationTypes`, `notificationSubjects`) and publish with `c.get("notifications").publish()`, optionally inside their own transaction. Events are stored once and fanned out by queue tasks in bounded batches with a saved cursor; receipts, inbox items, group membership, counters and deliveries are deduplicated by constraints. - Canonical per-user unread count and revision, exposed on the session payload and pushed as absolute state over the WebSocket after commit. Race-safe read boundaries for groups and for "mark all as read". - Preferences (in-app, email: none/immediate/daily/weekly), time zone and digest schedule, follow/mute, installation policy and mandatory types. - Immediate emails and DST-safe daily/weekly digests through the queue, with delivery records, retries, sanitized errors and provider idempotency keys (Resend forwards them; adapters may return an id). - Bell with dropdown, Notifications page, settings screen, FollowSubjectButton, and an AdminCP page for health, policy, failed deliveries, test email, reconciliation and retention cleanup. - Queue worker now reclaims tasks stuck in `processing` and can run a request's own tasks right after the response. - Blog plugin announces published articles to category followers. - The dashboard "send notification" widget also stores the message in the inbox. Postgres-backed integration tests, CI Postgres service, docs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G9U9fkruL5eKqCMVKcMvFL --- .github/workflows/build-lint-test.yml | 20 + .../migration.sql | 137 + .../snapshot.json | 11491 ++++++++++++++++ apps/web/content/docs/dev/advanced/queue.mdx | 19 +- .../content/docs/dev/email/custom-adapter.mdx | 29 +- apps/web/content/docs/dev/email/index.mdx | 20 + .../docs/dev/events/built-in-events.mdx | 4 + apps/web/content/docs/dev/meta.json | 1 + .../docs/dev/notifications/admincp.mdx | 124 + .../docs/dev/notifications/blog-example.mdx | 265 + .../dev/notifications/email-and-digests.mdx | 153 + .../dev/notifications/inbox-and-realtime.mdx | 205 + .../content/docs/dev/notifications/index.mdx | 200 + .../content/docs/dev/notifications/meta.json | 19 + .../migration-and-troubleshooting.mdx | 215 + .../dev/notifications/notification-types.mdx | 361 + .../docs/dev/notifications/preferences.mdx | 122 + .../docs/dev/notifications/publishing.mdx | 270 + .../dev/notifications/queue-and-scale.mdx | 130 + apps/web/content/docs/dev/websocket.mdx | 54 +- apps/web/src/locales/@vitnode/blog/pl.json | 3 +- apps/web/src/locales/@vitnode/core/pl.json | 98 +- packages/nodemailer/src/index.ts | 8 +- packages/resend/src/index.ts | 30 +- packages/vitnode/src/api/lib/module.ts | 12 + .../src/api/lib/notifications/core-types.ts | 25 + .../lib/notifications/digest-period.test.ts | 88 + .../api/lib/notifications/digest-period.ts | 170 + .../api/lib/notifications/preferences.test.ts | 88 + .../src/api/lib/notifications/preferences.ts | 104 + .../api/lib/notifications/registry.test.ts | 115 + .../src/api/lib/notifications/registry.ts | 356 + .../src/api/lib/notifications/safe.test.ts | 51 + .../vitnode/src/api/lib/notifications/safe.ts | 65 + packages/vitnode/src/api/lib/plugin.ts | 39 + .../src/api/middlewares/global.middleware.ts | 28 + packages/vitnode/src/api/models/email.ts | 27 +- .../src/api/models/notifications/admin.ts | 371 + .../api/models/notifications/email-queue.ts | 39 + .../notifications/email.integration.test.ts | 307 + .../src/api/models/notifications/email.ts | 814 ++ .../notifications/fanout.integration.test.ts | 253 + .../fanout.load.integration.test.ts | 192 + .../src/api/models/notifications/fanout.ts | 529 + .../notifications/inbox.integration.test.ts | 434 + .../src/api/models/notifications/inbox.ts | 555 + .../src/api/models/notifications/index.ts | 136 + .../api/models/notifications/maintenance.ts | 200 + .../api/models/notifications/preferences.ts | 210 + .../src/api/models/notifications/publish.ts | 242 + .../src/api/models/notifications/render.ts | 294 + .../src/api/models/notifications/run-now.ts | 10 + .../src/api/models/notifications/shared.ts | 138 + .../api/models/notifications/subscriptions.ts | 132 + .../src/api/modules/admin/admin.module.ts | 2 + .../notifications.admin.module.ts | 32 + .../notifications/routes/deliveries.route.ts | 100 + .../notifications/routes/maintenance.route.ts | 103 + .../notifications/routes/overview.route.ts | 77 + .../notifications/routes/settings.route.ts | 87 + .../admin/routes/notifications.route.ts | 20 +- .../notifications/notifications.module.ts | 53 + .../notifications/routes/list.route.ts | 60 + .../notifications/routes/mark.route.ts | 149 + .../notifications/routes/preferences.route.ts | 111 + .../notifications/routes/state.route.ts | 31 + .../routes/subscriptions.route.ts | 169 + .../src/api/modules/notifications/schema.ts | 70 + .../notifications/tasks/notification-tasks.ts | 80 + .../queue/helpers/process-queue-tasks.ts | 237 +- .../api/modules/users/routes/session.route.ts | 17 +- packages/vitnode/src/api/plugin.ts | 7 + .../vitnode/src/database/notifications.ts | 273 + packages/vitnode/src/database/relations.ts | 2 + packages/vitnode/src/emails/notification.tsx | 92 + .../vitnode/src/lib/notifications/types.ts | 48 + packages/vitnode/src/locales/api/en.json | 27 + packages/vitnode/src/locales/en.json | 290 +- .../src/pages/admin/system/notifications.tsx | 30 + packages/vitnode/src/pages/notifications.tsx | 35 + .../src/pages/settings/notifications.tsx | 45 + packages/vitnode/src/routes.test.ts | 2 + packages/vitnode/src/routes.tsx | 23 + .../src/tanstack/admin/notifications/index.ts | 26 + .../src/tanstack/admin/notifications/query.ts | 51 + .../tanstack/admin/notifications/route.tsx | 60 + .../tanstack/admin/notifications/screen.tsx | 45 + .../src/tanstack/admin/screen-queries.test.ts | 2 + packages/vitnode/src/tanstack/auth/queries.ts | 2 + .../vitnode/src/tanstack/auth/state.test.ts | 1 + .../vitnode/src/tanstack/layout/header.tsx | 2 + .../src/tanstack/notifications/actions.ts | 123 + .../src/tanstack/notifications/bell.test.tsx | 137 + .../src/tanstack/notifications/bell.tsx | 62 + .../src/tanstack/notifications/breadcrumb.tsx | 8 + .../src/tanstack/notifications/cache.ts | 63 + .../notifications/follow-subject-button.tsx | 124 + .../src/tanstack/notifications/index.ts | 7 + .../src/tanstack/notifications/namespaces.ts | 8 + .../tanstack/notifications/route-search.ts | 16 + .../src/tanstack/notifications/route.ts | 53 + .../src/tanstack/notifications/screen.tsx | 265 + .../notifications/settings-screen.tsx | 417 + .../notifications/state-sync.test.tsx | 165 + .../src/tanstack/notifications/state-sync.tsx | 135 + .../tanstack/realtime/realtime-listeners.tsx | 2 + packages/vitnode/src/tests/notifications.ts | 248 + packages/vitnode/src/tests/postgres.ts | 98 + .../layouts/sidebar/nav/nav-model.test.ts | 1 + .../admin/layouts/sidebar/nav/nav-model.tsx | 5 + .../notifications/deliveries-content.tsx | 328 + .../notifications/delivery-status-badge.tsx | 59 + .../system/notifications/health-content.tsx | 205 + .../core/system/notifications/health.test.ts | 116 + .../views/core/system/notifications/health.ts | 94 + .../notifications/maintenance-content.tsx | 216 + .../notifications/notifications-content.tsx | 60 + .../notifications/notifications-mutations.ts | 153 + .../notifications/notifications-query.ts | 174 + .../system/notifications/settings-form.tsx | 189 + .../core/system/notifications/statuses.ts | 18 + .../system/notifications/types-content.tsx | 261 + .../src/views/auth/settings/nav-content.tsx | 2 + .../views/auth/settings/settings-nav.test.ts | 8 +- .../src/views/auth/settings/settings-nav.ts | 4 +- .../layouts/theme/header/header-content.tsx | 4 + .../src/views/notifications/coalesce.test.ts | 48 + .../src/views/notifications/coalesce.ts | 34 + .../views/notifications/notification-item.tsx | 150 + .../notification-state-store.test.ts | 39 + .../notifications/notification-state-store.ts | 80 + .../notifications/notifications-actions.ts | 135 + .../notifications-bell-content.tsx | 182 + .../notifications/notifications-query.ts | 270 + packages/vitnode/src/ws/notifications.ts | 20 +- plugins/blog/src/api/lib/notifications.ts | 198 + .../src/api/modules/admin/admin.module.ts | 3 +- plugins/blog/src/config.api.ts | 8 + plugins/blog/src/locales/api/en.json | 12 + plugins/blog/src/locales/api/index.ts | 8 + plugins/blog/src/locales/en.json | 3 +- plugins/blog/src/pages/post-page.tsx | 13 + turbo.json | 3 +- 143 files changed, 27369 insertions(+), 128 deletions(-) create mode 100644 apps/api/migrations/20261004081732_add_core_notifications/migration.sql create mode 100644 apps/api/migrations/20261004081732_add_core_notifications/snapshot.json create mode 100644 apps/web/content/docs/dev/notifications/admincp.mdx create mode 100644 apps/web/content/docs/dev/notifications/blog-example.mdx create mode 100644 apps/web/content/docs/dev/notifications/email-and-digests.mdx create mode 100644 apps/web/content/docs/dev/notifications/inbox-and-realtime.mdx create mode 100644 apps/web/content/docs/dev/notifications/index.mdx create mode 100644 apps/web/content/docs/dev/notifications/meta.json create mode 100644 apps/web/content/docs/dev/notifications/migration-and-troubleshooting.mdx create mode 100644 apps/web/content/docs/dev/notifications/notification-types.mdx create mode 100644 apps/web/content/docs/dev/notifications/preferences.mdx create mode 100644 apps/web/content/docs/dev/notifications/publishing.mdx create mode 100644 apps/web/content/docs/dev/notifications/queue-and-scale.mdx create mode 100644 packages/vitnode/src/api/lib/notifications/core-types.ts create mode 100644 packages/vitnode/src/api/lib/notifications/digest-period.test.ts create mode 100644 packages/vitnode/src/api/lib/notifications/digest-period.ts create mode 100644 packages/vitnode/src/api/lib/notifications/preferences.test.ts create mode 100644 packages/vitnode/src/api/lib/notifications/preferences.ts create mode 100644 packages/vitnode/src/api/lib/notifications/registry.test.ts create mode 100644 packages/vitnode/src/api/lib/notifications/registry.ts create mode 100644 packages/vitnode/src/api/lib/notifications/safe.test.ts create mode 100644 packages/vitnode/src/api/lib/notifications/safe.ts create mode 100644 packages/vitnode/src/api/models/notifications/admin.ts create mode 100644 packages/vitnode/src/api/models/notifications/email-queue.ts create mode 100644 packages/vitnode/src/api/models/notifications/email.integration.test.ts create mode 100644 packages/vitnode/src/api/models/notifications/email.ts create mode 100644 packages/vitnode/src/api/models/notifications/fanout.integration.test.ts create mode 100644 packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts create mode 100644 packages/vitnode/src/api/models/notifications/fanout.ts create mode 100644 packages/vitnode/src/api/models/notifications/inbox.integration.test.ts create mode 100644 packages/vitnode/src/api/models/notifications/inbox.ts create mode 100644 packages/vitnode/src/api/models/notifications/index.ts create mode 100644 packages/vitnode/src/api/models/notifications/maintenance.ts create mode 100644 packages/vitnode/src/api/models/notifications/preferences.ts create mode 100644 packages/vitnode/src/api/models/notifications/publish.ts create mode 100644 packages/vitnode/src/api/models/notifications/render.ts create mode 100644 packages/vitnode/src/api/models/notifications/run-now.ts create mode 100644 packages/vitnode/src/api/models/notifications/shared.ts create mode 100644 packages/vitnode/src/api/models/notifications/subscriptions.ts create mode 100644 packages/vitnode/src/api/modules/admin/notifications/notifications.admin.module.ts create mode 100644 packages/vitnode/src/api/modules/admin/notifications/routes/deliveries.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/notifications/routes/maintenance.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/notifications/routes/overview.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/notifications/routes/settings.route.ts create mode 100644 packages/vitnode/src/api/modules/notifications/notifications.module.ts create mode 100644 packages/vitnode/src/api/modules/notifications/routes/list.route.ts create mode 100644 packages/vitnode/src/api/modules/notifications/routes/mark.route.ts create mode 100644 packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts create mode 100644 packages/vitnode/src/api/modules/notifications/routes/state.route.ts create mode 100644 packages/vitnode/src/api/modules/notifications/routes/subscriptions.route.ts create mode 100644 packages/vitnode/src/api/modules/notifications/schema.ts create mode 100644 packages/vitnode/src/api/modules/notifications/tasks/notification-tasks.ts create mode 100644 packages/vitnode/src/database/notifications.ts create mode 100644 packages/vitnode/src/emails/notification.tsx create mode 100644 packages/vitnode/src/lib/notifications/types.ts create mode 100644 packages/vitnode/src/pages/admin/system/notifications.tsx create mode 100644 packages/vitnode/src/pages/notifications.tsx create mode 100644 packages/vitnode/src/pages/settings/notifications.tsx create mode 100644 packages/vitnode/src/tanstack/admin/notifications/index.ts create mode 100644 packages/vitnode/src/tanstack/admin/notifications/query.ts create mode 100644 packages/vitnode/src/tanstack/admin/notifications/route.tsx create mode 100644 packages/vitnode/src/tanstack/admin/notifications/screen.tsx create mode 100644 packages/vitnode/src/tanstack/notifications/actions.ts create mode 100644 packages/vitnode/src/tanstack/notifications/bell.test.tsx create mode 100644 packages/vitnode/src/tanstack/notifications/bell.tsx create mode 100644 packages/vitnode/src/tanstack/notifications/breadcrumb.tsx create mode 100644 packages/vitnode/src/tanstack/notifications/cache.ts create mode 100644 packages/vitnode/src/tanstack/notifications/follow-subject-button.tsx create mode 100644 packages/vitnode/src/tanstack/notifications/index.ts create mode 100644 packages/vitnode/src/tanstack/notifications/namespaces.ts create mode 100644 packages/vitnode/src/tanstack/notifications/route-search.ts create mode 100644 packages/vitnode/src/tanstack/notifications/route.ts create mode 100644 packages/vitnode/src/tanstack/notifications/screen.tsx create mode 100644 packages/vitnode/src/tanstack/notifications/settings-screen.tsx create mode 100644 packages/vitnode/src/tanstack/notifications/state-sync.test.tsx create mode 100644 packages/vitnode/src/tanstack/notifications/state-sync.tsx create mode 100644 packages/vitnode/src/tests/notifications.ts create mode 100644 packages/vitnode/src/tests/postgres.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/deliveries-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/delivery-status-badge.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/health-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/health.test.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/health.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/maintenance-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/notifications-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/notifications-mutations.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/notifications-query.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/settings-form.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/statuses.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/types-content.tsx create mode 100644 packages/vitnode/src/views/notifications/coalesce.test.ts create mode 100644 packages/vitnode/src/views/notifications/coalesce.ts create mode 100644 packages/vitnode/src/views/notifications/notification-item.tsx create mode 100644 packages/vitnode/src/views/notifications/notification-state-store.test.ts create mode 100644 packages/vitnode/src/views/notifications/notification-state-store.ts create mode 100644 packages/vitnode/src/views/notifications/notifications-actions.ts create mode 100644 packages/vitnode/src/views/notifications/notifications-bell-content.tsx create mode 100644 packages/vitnode/src/views/notifications/notifications-query.ts create mode 100644 plugins/blog/src/api/lib/notifications.ts create mode 100644 plugins/blog/src/locales/api/en.json create mode 100644 plugins/blog/src/locales/api/index.ts 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/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/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" /> + { An interactive publish is emitted once, by the route that performed it, and carries no `scheduleId`. +The blog's own `content.blog.post.published` listener shows the other way out: +it announces the article with a notification idempotency key, so a second run +is a no-op. See [Example: blog category followers](/docs/dev/notifications/blog-example). + ### 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..5d47d8598 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/admincp.mdx @@ -0,0 +1,124 @@ +--- +title: Notifications in the AdminCP +description: Monitor and manage VitNode notifications in the AdminCP - delivery health, installation settings, per-type policy, email deliveries with retry, test email, unread count repair and cleanup. +icon: LayoutDashboard +--- + +Open **AdminCP → System → Notifications** (`/admin/core/system/notifications`) +to see whether notifications are flowing, decide which types run on your site, +and fix things when they drift. + +{/* Image prompt: The VitNode AdminCP page at /admin/core/system/notifications, light theme, 1440x900. Top row of health cards: "Notification email: Ready", "Background worker: Running", "Events" with small status counts, "Email deliveries" with sent/failed counts, "Oldest pending event: Nothing waiting". Below, an "Installation settings" card with a "Send notification emails" switch and number inputs, then a "Notification types" list with In-app/Email controls per type. */} + +## Permissions + +The screen uses the core `notifications` staff permission module: + +| Permission | Allows | +| -------------------------- | ----------------------------------------------------------------------------- | +| `notifications.can_view` | Open the screen: health, settings, types and deliveries, read-only | +| `notifications.can_edit` | Change installation settings and per-type policy | +| `notifications.can_manage` | Retry deliveries, send a test email, check and fix unread counts, run cleanup | + +Grant them in **Staff → Administrators** (`/admin/core/staff/admins`). See +[Staff permissions](/docs/dev/working-with-users/staff-permissions). + +## Health + +The health cards answer "is anything stuck?": + +- **Notification email** - ready, turned off, or no adapter configured. +- **Background worker** - running, stale or not configured. Nothing is + delivered without a working [cron adapter](/docs/dev/cron). +- **Events** - how many are pending, processing, completed or failed. +- **Email deliveries** - counts per status. +- **Oldest pending event** - the longer this gets, the later people hear about + things. + +A warning banner lists failed deliveries, failed events, failed notification +queue tasks and a missing or stale cron. + +## Installation settings + +| Setting | Default | Range | Effect | +| ------------------------ | ------- | ------- | --------------------------------------------------------------------------------------- | +| Send notification emails | On | - | Off keeps every notification in-app, whatever users picked. Account email is unaffected | +| Keep notifications for | 90 days | 1-3650 | Inbox items older than this are removed by the nightly cleanup | +| Fan-out batch size | 500 | 10-5000 | Recipients handled per fan-out transaction | +| Email batch size | 50 | 1-500 | Deliveries picked up per email run | +| Email concurrency | 4 | 1-20 | Emails sent in parallel during a run | + +Changes apply from the next batch. + +## Per-type policy + +Every registered type is listed with its plugin, category, version and whether +it is grouped or mandatory. For each type you can set: + +- **Enabled** - off stops the type for everyone. Mandatory types are always on. +- **Allow email** - off removes the email channel for this type. +- **In-app by default** and **Default email** - the installation default for + users who never chose. + +Users' own choices still win over the defaults. See +[How core decides](/docs/dev/notifications/preferences#how-core-decides). + +## Deliveries + +A list of email delivery records, newest first, filterable by status. Each row +shows the type, mode (immediate, daily, weekly or test), attempts, item count, +a sanitized error and the recipient's **user id**. + +**Retry** puts a failed delivery back in line with fresh attempts. It keeps the +same row and idempotency key, so the provider sees the same key and the same +notifications are rendered. See +[Email and digests](/docs/dev/notifications/email-and-digests#delivery-records). + +## Maintenance + +- **Send test email to me** queues a sample notification email to the signed-in + administrator's own address. It cannot be sent to anyone else. +- **Check unread counts** compares every stored unread count with the actual + inbox and reports how many people differ. It changes nothing. +- **Fix unread counts** recalculates every mismatched count from the inbox and + pushes the corrected badge to those users right away. +- **Run cleanup now** queues the retention cleanup instead of waiting for 03:17. + +## What diagnostics hide + +The screen is built for finding faults without reading anyone's mail: + +- recipients show as user ids - never email addresses; +- no notification titles, bodies or email subjects are shown; +- provider errors are stored with email addresses, tokens and keys redacted. + +## Admin API + +All routes live under `/api/@vitnode/core/admin/notifications`: + +| Method | Path | Permission | +| ------ | ------------------------ | ------------ | +| `GET` | `/overview` | `can_view` | +| `GET` | `/deliveries` | `can_view` | +| `PUT` | `/settings` | `can_edit` | +| `PUT` | `/types/{type}` | `can_edit` | +| `POST` | `/deliveries/{id}/retry` | `can_manage` | +| `POST` | `/test-email` | `can_manage` | +| `POST` | `/reconcile` | `can_manage` | +| `POST` | `/cleanup` | `can_manage` | + +`POST /reconcile` takes `{ "dryRun": true }` to check only, and an optional +`userId` to look at one user. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/notifications/blog-example.mdx b/apps/web/content/docs/dev/notifications/blog-example.mdx new file mode 100644 index 000000000..ce685a461 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/blog-example.mdx @@ -0,0 +1,265 @@ +--- +title: 'Example: blog category followers' +description: A real VitNode notifications integration - the blog plugin tells readers who follow a category when a new article is published, with a follow button on every article. +icon: Newspaper +--- + +The `@vitnode/blog` plugin uses the Notifications Center for one feature: readers +follow a blog category, and get "New article: …" when an article in it is +published. All of it lives in `plugins/blog/src/api/lib/notifications.ts`, and +it touches every part of the API. + + + + + +### Declare the type + +```ts title="plugins/blog/src/api/lib/notifications.ts" +export const postPublishedNotification = buildNotificationType({ + id: 'blog.post_published', + version: 1, + schema: z.object({ + postId: z.number().int().positive(), + slug: z.string().min(1).max(255), + title: z.string().min(1).max(255), + }), + category: 'content', + label: '@vitnode/blog.notifications.post_published.label', + description: '@vitnode/blog.notifications.post_published.description', + subjectType: 'blog.post', + defaults: { email: 'daily', inApp: true }, + email: true, + access: async ({ c, data, userIds }) => + (await isPublished(c, data.postId)) ? userIds : [], + present: ({ data, t }) => ({ + body: t('@vitnode/blog.notifications.post_published.body'), + target: `/blog/${encodeURIComponent(data.slug)}`, + title: t('@vitnode/blog.notifications.post_published.title', { + title: data.title, + }), + }), +}) +``` + +- **`access`** gives every recipient the same answer, so it checks once and + returns all of `userIds` or none. Unpublish the article, and inbox items turn + into placeholders and pending digests drop it. +- **`subjectType: "blog.post"`** - no plugin declares a `blog.post` subject, so + core makes it mute-only. A reader can mute one article without unfollowing + the category. +- **`defaults`** - in-app, plus a daily digest by email. + + + + + +### Declare the subject readers follow + +```ts title="plugins/blog/src/api/lib/notifications.ts" +export const blogCategorySubject = buildNotificationSubject({ + type: 'blog.category', + canFollow: async ({ c, id }) => { + const categoryId = Number(id) + if (!Number.isSafeInteger(categoryId)) return false + + const [row] = await c + .get('db') + .select({ id: blog_categories.id }) + .from(blog_categories) + .where(eq(blog_categories.id, categoryId)) + .limit(1) + + return !!row + }, + resolveLabels: async ({ c, ids, locale }) => { + const categoryIds = ids.map(Number).filter(Number.isSafeInteger) + if (categoryIds.length === 0) return {} + + const rows = await c + .get('db') + .select({ + code: core_languages.code, + id: categoryTranslations.itemId, + name: categoryTranslations.name, + }) + .from(categoryTranslations) + .innerJoin( + core_languages, + eq(core_languages.id, categoryTranslations.languageId), + ) + .where(inArray(categoryTranslations.itemId, categoryIds)) + + const labels: Record = {} + for (const row of rows) { + // The reader's language wins; any other translation is a fallback. + if (row.code === locale || !(String(row.id) in labels)) { + labels[String(row.id)] = row.name + } + } + + return labels + }, +}) +``` + +`canFollow` only allows categories that exist. `resolveLabels` turns category +ids into names for the follow list on `/settings/notifications`, in the +reader's language, with one query for all ids. + + + + + +### Register both, with the API messages + +```ts title="plugins/blog/src/config.api.ts" +import apiMessages from '@/locales/api' + +export const blogApiPlugin = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + messages: apiMessages, + notificationTypes: [postPublishedNotification], + notificationSubjects: [blogCategorySubject], + modules: [adminModule /* ... */], + }) +``` + +```json title="plugins/blog/src/locales/api/en.json" +{ + "@vitnode/blog": { + "notifications": { + "post_published": { + "label": "New articles in categories you follow", + "description": "When an article is published in a blog category you follow.", + "title": "New article: {title}", + "body": "Fresh off the press in a category you follow." + } + } + } +} +``` + + + + + +### Publish from the Content Engine event + +The blog runs on the [Content Engine](/docs/dev/content-engine), which emits +`content.blog.post.published`. A listener turns it into one notification for +the followers of every category the article is in. `loadPostSummary()` reads +the article's title and slug in its default language: + +```ts title="plugins/blog/src/api/lib/notifications.ts" +export const notifyCategoryFollowersListener = buildEventListener({ + event: contentEventName(blogPostContentType.id, 'published'), + name: 'notify-category-followers', + handler: async (c, payload) => { + const postId = payload.contentId + const [summary, categories] = await Promise.all([ + loadPostSummary(c, postId), + c + .get('db') + .select({ categoryId: blog_posts_category_id.relatedItemId }) + .from(blog_posts_category_id) + .where(eq(blog_posts_category_id.itemId, postId)) + .orderBy(asc(blog_posts_category_id.position)), + ]) + if (!summary || categories.length === 0) return + + await c.get('notifications').publish({ + type: postPublishedNotification, + actorId: payload.scheduledBy ?? c.get('admin')?.user.id ?? null, + followersOf: categories.map((row) => ({ + id: row.categoryId, + type: 'blog.category', + })), + subject: { id: postId, type: 'blog.post' }, + data: { postId, slug: summary.slug, title: summary.title }, + idempotencyKey: `post-published:${postId}`, + }) + }, +}) +``` + +It is registered on the top-level admin module: + +```ts title="plugins/blog/src/api/modules/admin/admin.module.ts" +events: [...blogLegacyEventListeners, notifyCategoryFollowersListener], +``` + +- **`followersOf`** names up to 20 categories. A reader following two of them + gets one notification. Core streams the followers, so a category with ten + thousand of them still costs this listener one query. +- **`actorId`** is whoever pressed publish, or whoever booked the schedule. They + are left out of their own announcement. +- **`idempotencyKey`** is per article. An article is announced once, like its + `publishedAt` that records the first publication: unpublishing and publishing + again, or a retried scheduled publish, does not notify anyone twice. + + + + + +### Add the follow button + +```tsx title="plugins/blog/src/pages/post-page.tsx" +import { FollowSubjectButton } from '@vitnode/core/tanstack/notifications' + +// Rendered in the article header, under the title +const followCategory = + item.categoryId[0] === undefined ? null : ( +
+

+ {t('follow_category')} +

+ +
+ ) +``` + +The article's `categoryId` list comes from the public content API. The button +follows the first category. + +Guests see nothing. Signed-in readers get **Follow** and a mute toggle. + +
+ +
+ +{/* Image prompt: A VitNode blog article page on a phone, 390x844, light theme. Under the article title, a muted line "Want to hear about new articles in this category?" next to an outlined "Follow" button with a bell-plus icon and a small square mute button with a bell-off icon. */} + +## How durable is it? + + + The Content Engine emits events for interactive saves **after** its + transaction commits. If the process crashes between that commit and the + listener's `publish()`, that one announcement is skipped. The article itself + is safe. + + +Scheduled publication runs in a durable queue task that retries until it +succeeds. It is **at least once**, so the listener can run twice - and the +idempotency key turns the second run into a no-op. + +For producers you own, skip the gap entirely: call `publish()` with `tx` inside +the same transaction as your write. See +[Publish inside your transaction](/docs/dev/notifications/publishing#publish-inside-your-transaction). + +## 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..9338ab63a --- /dev/null +++ b/apps/web/content/docs/dev/notifications/email-and-digests.mdx @@ -0,0 +1,153 @@ +--- +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 their `digestHour`. A weekly one covers seven days + ending on their `digestWeekday`. Defaults: 08:00, Monday. +- **Time zone fallback.** The user's own time zone, 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, and their mutes; +- the installation policy and the global email switch; +- 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 from the AdminCP](/docs/dev/notifications/admincp#deliveries) 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..4ac1e98e8 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/inbox-and-realtime.mdx @@ -0,0 +1,205 @@ +--- +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, channels, digest schedule and time zone | +| `PUT` | `/preferences` | Save them | +| `GET` | `/subscriptions` | Followed and muted subjects, optional `state` filter | +| `GET` | `/subscriptions/{subjectType}/{subjectId}` | One subject's state | +| `PUT` | `/subscriptions` | Body `{ subjectType, subjectId, state }`, state is `following`, `muted` or `none` | + +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` - per type, digest schedule, follows and mutes | +| `NotificationStateSync` | Mounted with the realtime listeners. Keeps the count in step | +| `FollowSubjectButton` | Drop it on your own plugin pages | + +Add follow and mute controls to any page: + +```tsx title="plugins/forum/src/pages/topic-page.tsx" +import { FollowSubjectButton } from '@vitnode/core/tanstack/notifications' + +export const FollowTopic = ({ topicId }: { topicId: number }) => ( + +) +``` + +It renders nothing for guests. Pass `canMute={false}` to hide the mute toggle. +The subject type must be registered with `buildNotificationSubject()`. + +{/* 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..b3a0b8209 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/index.mdx @@ -0,0 +1,200 @@ +--- +title: Notifications +description: Add persistent notifications to a VitNode plugin - an inbox, a live unread count, follow and mute, email and 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 seven `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 types and subjects | Builds the preferences page from them | +| Picks candidates: `recipients` and `followersOf` | Removes the actor, deleted users, muted 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 → muted? 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..d0509cd46 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/meta.json @@ -0,0 +1,19 @@ +{ + "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---", + "blog-example", + "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..29d4d06f7 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/migration-and-troubleshooting.mdx @@ -0,0 +1,215 @@ +--- +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 seven 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, digest schedule and preferences | +| `core_notification_subscriptions` | Follows and mutes | +| `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: + +- the **Background worker** card in + [AdminCP → System → Notifications](/docs/dev/notifications/admincp#health) + says "Not configured" or "Stale" - fix your [cron adapter](/docs/dev/cron); +- **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 - the **Notification + email** card says "Ready". +2. **Send notification emails** is on in the installation settings. +3. The type's policy has **Allow email** on. +4. The user picked an email mode for the type - the default may be `none`. +5. The **deliveries** list: `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 + +Open **AdminCP → System → Notifications → Maintenance** and click **Check +unread counts**. If anyone differs, **Fix unread counts** recalculates them 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 user's time zone from `/settings/notifications`. Without +one, the time zone of their language is used, then UTC. The default hour is +08:00, and weekly digests go out on Monday. 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..0c684fc69 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/notification-types.mdx @@ -0,0 +1,361 @@ +--- +title: Notification types +description: Reference for buildNotificationType and buildNotificationSubject - 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. +A subject is something users can follow or mute, such as a forum topic or a +blog category. + +## 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 and subjects + +Pass them to `buildApiPlugin()`: + +```ts title="plugins/forum/src/config.api.ts" +export const forumApiPlugin = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + messages: apiMessages, + notificationTypes: [topicReplyNotification], + notificationSubjects: [forumTopicSubject], + 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 ++] + notificationSubjects: [forumTopicSubject], // [!code ++] +}) +``` + +Core collects both lists from every installed plugin when the API starts. The +same type id or subject type 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 muted or 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. + +## Declare a subject + +A subject is anything users can follow or mute. Following puts a user in the +audience whenever a publisher names the subject in `followersOf`. Muting +silences every notification about it. + +```ts title="plugins/forum/src/api/lib/notifications.ts" +import { buildNotificationSubject } from '@vitnode/core/api/lib/notifications/registry' + +export const forumTopicSubject = buildNotificationSubject({ + type: 'forum.topic', + canFollow: async ({ c, id, userId }) => + (await canReadTopic(c, Number(id), [userId])).length > 0, + resolveLabels: async ({ c, ids }) => await loadTopicTitles(c, ids), +}) +``` + + boolean | Promise', + }, + followable: { + description: 'Set false for subjects that can only be muted.', + type: 'boolean', + default: 'true', + }, + resolveLabels: { + description: + 'Human-readable names for the follow and mute lists on the settings page. Ids it leaves out show as raw ids.', + type: '(args: { c, ids: string[], locale: string }) => Record | Promise>', + }, + }} +/> + +A type's `subjectType` that no plugin declared becomes a **mute-only** subject +automatically, so users can always mute whatever a notification is about. + +## Mandatory types + +```ts +mandatory: true, +``` + +Use `mandatory` for account and security notices users must not miss in the +inbox: always delivered in-app, never muted, 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..8831787ff --- /dev/null +++ b/apps/web/content/docs/dev/notifications/preferences.mdx @@ -0,0 +1,122 @@ +--- +title: Preferences +description: How VitNode decides which notifications each user receives in-app and by email - installation policy, mandatory types, mutes, user choices, digest schedule and time zone. +icon: SlidersHorizontal +--- + +Users choose, per notification type, whether they get it in-app and how they +get it 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. Cards grouped by category ("Content", "Social", "System"); each row shows a type label, a one-line description, an "In-app" switch and an "Email" select with None, Immediately, Daily digest and Weekly digest. Below, a "Digest schedule" card with time zone, hour and weekday selects, and a "Following and muted" card listing "Blog: Releases" with an Unfollow button. */} + +## 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 muted the subject** → nothing. +4. **The user's own choice** for the type. +5. **The installation default** for the type, set in the + [AdminCP](/docs/dev/notifications/admincp). +6. **The type's own `defaults`.** + +In-app and email are resolved separately. A user can take a type by email only, +in-app only, both or neither. + +### When email is available + +A type can email only when all four allow it: + +- the type declares `email`; +- an [email adapter](/docs/dev/email) is configured; +- notification email is switched on in the AdminCP; +- the type's policy does not set "allow email" off. + +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 | +| Muted the topic | no | none | +| Any user, after "allow email" off | yes | none | + +## What users can change + +`PUT /api/@vitnode/core/notifications/preferences` accepts a partial update: + +```json +{ + "types": { + "forum.topic_reply": { "inApp": true, "email": "weekly" } + }, + "timeZone": "Europe/Warsaw", + "digestHour": 8, + "digestWeekday": 1 +} +``` + +The route answers `400` for an unknown type, a change to a mandatory type, an +email mode for a type that cannot email, or an unknown time zone. Choices are +stored per user and merged, so saving one type never resets another. + +## Digest schedule and time zone + +| Setting | Values | Default | +| --------------- | ----------------------------- | ---------------------------------------------- | +| `timeZone` | Any IANA name, or `null` | The user's language time zone, then UTC | +| `digestHour` | `0`-`23` | `8` - daily and weekly digests go out at 08:00 | +| `digestWeekday` | `0` (Sunday) - `6` (Saturday) | `1` - weekly digests go out on Monday | + +Digest periods are calendar days and weeks in that time zone. See +[Email and digests](/docs/dev/notifications/email-and-digests#daily-and-weekly-digests). + +## Follow and mute + +Each user has one state per subject: `following`, `muted` or none. Muting a +followed subject unfollows it, and following a muted one unmutes it. + +- **Follow** adds the user to every audience that names the subject in + `followersOf`. The subject's `canFollow` must agree. +- **Mute** silences every notification about the subject - in-app and email - + even when the user was named in `recipients`. + +Users follow and mute with [`FollowSubjectButton`](/docs/dev/notifications/inbox-and-realtime#frontend-pieces) +on plugin pages, and unfollow or unmute from `/settings/notifications`. Plugins +can do the same from code with +[`follow()` and `mute()`](/docs/dev/notifications/publishing#follow-and-mute-from-code). + +## 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, cannot be +muted, 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, the + global email switch 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..ec7292482 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/publishing.mdx @@ -0,0 +1,270 @@ +--- +title: Publish notifications +description: Publish a notification from a VitNode plugin with c.get("notifications").publish() - inside your transaction, with an idempotency key, to named recipients or followers of a subject. +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], + followersOf: [{ type: 'forum.topic', id: topic.id }], + 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[]', + }, + followersOf: { + description: + 'Everyone following any of these subjects is a candidate too. At most 20 subjects.', + type: '{ type: string; id: number | string }[]', + }, + subject: { + description: + "What the notification is about. Drives muting, 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 + +There are two ways to name candidates, and you can use both in one call: + +- **`recipients`** - user ids your plugin picked: the topic author, the + mentioned users, the members of a group. +- **`followersOf`** - subjects users follow. Core streams the follower list + itself, in batches, so a topic with 50,000 followers costs your code nothing. + +A user who is in both lists, or follows two of the subjects, gets the +notification once. Candidates are only candidates. Core still drops: + +- the actor, unless `allowSelf: true`; +- user ids that do not exist; +- users who muted the `subject`; +- 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` and no `followersOf` 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 20 `followersOf` subjects or 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. + +## Follow and mute from code + +The model exposes the same follow and mute state the UI uses: + +```ts +const notifications = c.get('notifications') +const subject = { type: 'forum.topic', id: topic.id } + +await notifications.follow(user.id, subject) +await notifications.mute(user.id, subject) +await notifications.unmute(user.id, subject) +await notifications.unfollow(user.id, subject) + +const state = await notifications.subscription(user.id, subject) +// "following" | "muted" | "none" +``` + +A typical use is auto-following a topic for its author when it is created. +`follow()` checks the subject's `canFollow` and throws a 403 when it refuses, +or a 400 for an unknown or mute-only subject type. +`unfollow()` and `unmute()` only clear the matching state. `state(userId)` +returns a user's `{ unread, revision }`. + +## 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..23dff2551 --- /dev/null +++ b/apps/web/content/docs/dev/notifications/queue-and-scale.mdx @@ -0,0 +1,130 @@ +--- +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` | `17 3 * * *` | Queues the daily retention cleanup at 03:17 | + +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, configurable from 10 to 5,000 in the AdminCP). Named +`recipients` come first, then each `followersOf` subject's followers, streamed +in user id order. + +Each batch is **one short transaction** that: + +1. locks the event row; +2. filters the batch - deleted users, mutes, 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-follower 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 candidates: 600 followers of a subject plus 601 named recipients, 400 of +them in both lists, one follower who muted the subject, and the actor among the +named ones. + +| Measurement | Result | +| -------------------------------------------- | ------------------------------------------------- | +| Inbox items created | 999 - the muted follower and the actor got none | +| `publish()` | 8 ms | +| Fan-out with two workers racing on the event | 531 ms | +| SQL queries, batch size 200 | 103 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_subscriptions` | Streaming followers of a subject in user id order | +| `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), + 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). + +## 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 + { 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, + }, + // Resend drops a second send with the same key for 24 hours, which is + // what makes a retry after an unconfirmed send safe. + idempotencyKey ? { idempotencyKey } : undefined, + ); if (error) { throw new Error(`[${error.name}]: ${error.message}`); } + + return { id: data?.id }; }, }; }; diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts index 4de66b35f..b360c67ce 100644 --- a/packages/vitnode/src/api/lib/module.ts +++ b/packages/vitnode/src/api/lib/module.ts @@ -6,6 +6,10 @@ 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, + NotificationSubjectDefinition, +} from "./notifications/registry"; import type { BuildQueueTaskReturn } from "./queue"; import type { Route } from "./route"; import type { BuildWebSocketReturn } from "./websocket"; @@ -28,6 +32,8 @@ export interface BaseBuildModuleReturn< hono: OpenAPIHono; modules?: BaseBuildModuleReturn

[]; name: M; + notificationSubjects?: NotificationSubjectDefinition[]; + notificationTypes?: AnyNotificationTypeDefinition[]; pluginId: P; publicContentTypes?: AnyContentTypeDefinition[]; queueTasks: BuildQueueTaskReturn[]; @@ -57,6 +63,8 @@ export function buildModule< modules, contentModels, contentTypes, + notificationSubjects, + notificationTypes, publicContentTypes, cronJobs = [], events = [], @@ -70,6 +78,8 @@ export function buildModule< events?: BuildEventListenerReturn[]; modules?: Modules; name: M; + notificationSubjects?: NotificationSubjectDefinition[]; + notificationTypes?: AnyNotificationTypeDefinition[]; pluginId: P; publicContentTypes?: AnyContentTypeDefinition[]; queueTasks?: BuildQueueTaskReturn[]; @@ -101,6 +111,8 @@ export function buildModule< contentTypes, cronJobs, events, + notificationSubjects, + 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..6bae957c9 --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/core-types.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +import { buildNotificationType } from "./registry"; + +/** + * A message an administrator sends a user from the dashboard widget. Before + * the Notifications Center it was a toast that vanished on reload; now it + * also lands in the inbox. + */ +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 }, + // No target: the whole message is the item, so clicking it only marks it read. + 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..a2bbad66c --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/digest-period.test.ts @@ -0,0 +1,88 @@ +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 an hour that does not exist to the end of the gap", () => { + // 02:00-03:00 on 29 March 2026 never happened in Warsaw. + expect( + zonedHourToUtc({ day: 29, month: 3, year: 2026 }, 2, "Europe/Warsaw"), + ).toEqual(new Date("2026-03-29T01:00:00Z")); + }); + + it("picks the first of an hour that happens twice", () => { + // 01:00 happened twice in New York 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 already 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 period = latestEndedDigestPeriod({ + hour: 9, + mode: "weekly", + now: new Date("2026-10-08T12:00:00Z"), // a Thursday + 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..3192cbc41 --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/digest-period.ts @@ -0,0 +1,170 @@ +export type DigestMode = "daily" | "weekly"; + +export interface LocalDate { + day: number; + month: number; + year: number; +} + +export interface DigestPeriod { + end: Date; + /** The local calendar date the digest belongs to, e.g. `2026-10-04`. */ + 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; +} + +export 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; +}; + +/** + * The instant a local wall-clock hour happens in `timeZone`. On the night + * clocks jump forward the hour may not exist; the answer is then the first + * instant after the gap. On the night they fall back it happens twice; the + * earlier one wins. Either way each local date maps to exactly one instant. + */ +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 candidates = [ + wall - Math.max(before, after), + wall - Math.min(before, after), + ] + .filter(instant => offsetAt(instant, timeZone) === wall - instant) + .sort((a, b) => a - b); + + if (candidates[0] !== undefined) return new Date(candidates[0]); + + // A gap: the wall time never happens. Use the instant clocks jump to. + return new Date(wall - before); +}; + +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")}`; + +/** + * The most recent digest period that has already ended at `now`. A daily digest + * ends every local day at `hour`; a weekly one on `weekday` at `hour`. Periods + * are contiguous, so on DST days a daily period is 23 or 25 hours long rather + * than skipping or repeating a day. + */ +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..873135bbc --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/preferences.test.ts @@ -0,0 +1,88 @@ +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, + muted: false, + ...args, + }); + +describe("resolveNotificationChannels", () => { + it("falls back from the user, to the installation, to the type", () => { + expect(resolve({})).toEqual({ email: "daily", inApp: true }); + expect(resolve({ policy: { email: "weekly", inApp: false } })).toEqual({ + email: "weekly", + inApp: false, + }); + expect( + resolve({ + policy: { email: "weekly", inApp: false }, + preference: { email: "immediate", inApp: true }, + }), + ).toEqual({ email: "immediate", inApp: true }); + }); + + it("lets a mute beat the user's own preferences", () => { + expect( + resolve({ muted: true, preference: { email: "immediate", inApp: true } }), + ).toEqual({ email: "none", inApp: false }); + }); + + it("lets installation policy beat everything but mandatory types", () => { + expect( + resolve({ policy: { enabled: false }, preference: { inApp: true } }), + ).toEqual({ + email: "none", + inApp: false, + }); + expect( + resolve({ + definition: mandatory, + muted: true, + policy: { enabled: false }, + }), + ).toEqual({ email: "daily", inApp: 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, + }); + }); +}); 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..c7972d18c --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/preferences.ts @@ -0,0 +1,104 @@ +import type { NotificationTypePreference } from "@/database/notifications"; +import type { NotificationEmailMode } from "@/lib/notifications/types"; + +import type { AnyNotificationTypeDefinition } from "./registry"; + +/** Installation-wide policy for one type, edited in the AdminCP. */ +export interface NotificationTypePolicy { + /** `false` keeps the type off email whatever users choose. */ + allowEmail?: boolean; + /** Default email mode for users who never chose one. */ + email?: NotificationEmailMode; + /** `false` stops the type entirely (mandatory types ignore it). */ + enabled?: boolean; + /** Default in-app setting for users who never chose one. */ + inApp?: boolean; +} + +export interface NotificationGlobalSettings { + /** Upper bound on emails one drain run sends. */ + emailBatchSize: number; + /** Emails sent in parallel within one drain run. */ + emailConcurrency: number; + /** Master switch for notification email - account email is unaffected. */ + emailEnabled: boolean; + /** Recipients handled per fan-out transaction. */ + fanoutBatchSize: number; + /** Inbox items older than this many days are removed by cleanup. */ + retentionDays: number; +} + +export const DEFAULT_NOTIFICATION_SETTINGS: NotificationGlobalSettings = { + emailBatchSize: 50, + emailConcurrency: 4, + emailEnabled: true, + fanoutBatchSize: 500, + retentionDays: 90, +}; + +export interface NotificationChannels { + email: NotificationEmailMode; + inApp: boolean; +} + +const OFF: NotificationChannels = { email: "none", inApp: false }; + +/** Whether this type can email at all on this installation. */ +export const isNotificationEmailAvailable = ({ + definition, + emailConfigured, + policy, +}: { + definition: AnyNotificationTypeDefinition; + /** An email adapter exists and notification email is switched on. */ + emailConfigured: boolean; + policy?: NotificationTypePolicy; +}): boolean => + emailConfigured && !!definition.email && policy?.allowEmail !== false; + +/** + * The single place that decides what a user receives, in this order: + * + * 1. Installation policy disables the type → nothing (mandatory types excepted). + * 2. Mandatory types → always in-app; email follows the installation default. + * 3. The user muted the subject → nothing. + * 4. The user's own choice for the type. + * 5. The installation default for the type. + * 6. The type's own default. + * + * Email is additionally limited to what the type and installation offer, and + * never depends on the in-app choice. + */ +export const resolveNotificationChannels = ({ + definition, + emailConfigured, + muted, + policy, + preference, +}: { + definition: AnyNotificationTypeDefinition; + emailConfigured: boolean; + muted: 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; + + if (definition.mandatory) { + return { email: emailAvailable ? defaultEmail : "none", inApp: true }; + } + + if (muted) return OFF; + + return { + email: emailAvailable ? (preference?.email ?? defaultEmail) : "none", + inApp: preference?.inApp ?? policy?.inApp ?? definition.defaults.inApp, + }; +}; 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..da2d01fdc --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/registry.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + buildNotificationSubject, + 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"/); + }); + + it("refuses a subject registered twice", () => { + const subject = buildNotificationSubject({ type: "blog.category" }); + + expect(() => + createNotificationRegistry( + [], + [ + { definition: subject, pluginId: "@acme/a" }, + { definition: subject, pluginId: "@acme/b" }, + ], + ), + ).toThrow(/Duplicate notification subject/); + }); + + it("makes a type's undeclared subject mutable but not followable", () => { + const registry = createNotificationRegistry([ + { + definition: { ...comment, subjectType: "blog.post" }, + pluginId: "@acme/a", + }, + ]); + + expect(registry.getSubject("blog.post")?.definition.followable).toBe(false); + }); +}); + +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..82b5ef684 --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/registry.ts @@ -0,0 +1,356 @@ +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"; + +/** Plain text a type renders for one inbox item. Never HTML. */ +export interface NotificationPresentation { + body?: string; + /** + * Where clicking the item goes: a path on this site, such as `/blog/hello`. + * Anything else - another origin, `javascript:`, `//evil.com` - is dropped + * and the item renders without a link. + */ + 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 { + /** Distinct actors across every event of the item. */ + actorCount: number; + /** The most recent distinct actors of the item, newest first (max 3). */ + actors: NotificationActorPreview[]; + data: TData; + /** Events grouped into the item, 1 when the type is not grouped. */ + eventCount: number; + locale: string; + subject: NotificationSubject | null; + t: Translator; +} + +export interface NotificationAccessArgs { + c: Context; + data: TData; + subject: NotificationSubject | null; + /** The candidates to check - a bounded batch, never the whole audience. */ + userIds: number[]; +} + +export interface NotificationGroupingConfig { + /** + * What makes two events "the same thing". Defaults to the subject, and an + * event without a subject (or a `null` key) is never grouped. + */ + key?: (args: { + data: TData; + subject: NotificationSubject | null; + }) => null | string; + /** Events landing in the same window join one inbox item. */ + windowMinutes: number; +} + +export interface NotificationTypeDefinition> { + /** + * Returns which of `userIds` may see this notification. Called in bounded + * batches during fan-out, again before an email goes out, and for every item + * a user lists - so it should answer for many users with one query. + */ + access?: ( + args: NotificationAccessArgs, + ) => number[] | Promise; + category: string; + defaults: { email: NotificationEmailMode; inApp: boolean }; + /** Message key of the one-line description shown in preferences. */ + description?: string; + /** + * Turns on the email channel. `true` reuses `present` for the email; a + * function words it separately. + */ + email?: + | ((args: NotificationPresentArgs) => NotificationEmailPresentation) + | boolean; + grouping?: NotificationGroupingConfig; + /** Plugin-scoped, dot-separated id such as `blog.post_published`. */ + id: string; + /** Message key of the preference label. */ + label: string; + /** + * Account and security messages. Always shown in-app, never muted, and the + * user cannot opt out of its email default. + */ + mandatory?: boolean; + /** Upgrades data stored by an older `version` before it is parsed. */ + migrate?: (data: unknown, fromVersion: number) => unknown; + present: (args: NotificationPresentArgs) => NotificationPresentation; + schema: z.ZodType; + /** The subject type this notification is about, e.g. `blog.post`. */ + subjectType?: string; + /** Bump when `schema` changes shape; see `migrate`. */ + version: number; +} + +export type AnyNotificationTypeDefinition = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + NotificationTypeDefinition; + +export interface RegisteredNotificationType { + definition: AnyNotificationTypeDefinition; + pluginId: string; +} + +/** + * Something users can follow or mute, such as a blog category. Following adds + * a user to the audience whenever a publisher names the subject in + * `followersOf`; muting silences every notification about it. + */ +export interface NotificationSubjectDefinition { + /** + * Whether `userId` may follow subject `id` - typically "can they see it". + * Not called for mutes and unfollows, which only ever reduce what a user + * receives. + */ + canFollow?: (args: { + c: Context; + id: string; + userId: number; + }) => boolean | Promise; + /** `false` for subjects that can only be muted. */ + followable?: boolean; + /** + * Human-readable names for the settings page's follow and mute lists, e.g. + * category titles. Ids it leaves out show as their raw id. + */ + resolveLabels?: (args: { + c: Context; + ids: string[]; + locale: string; + }) => Promise> | Record; + type: string; +} + +export interface RegisteredNotificationSubject { + definition: NotificationSubjectDefinition; + pluginId: string; +} + +export function buildNotificationSubject( + definition: NotificationSubjectDefinition, +): NotificationSubjectDefinition { + if ( + !SUBJECT_TYPE_PATTERN.test(definition.type) || + definition.type.length > 100 + ) { + throw new NotificationRegistryError( + `Invalid notification subject type "${definition.type}".`, + ); + } + + return definition; +} + +export type NotificationDataOf = + T extends NotificationTypeDefinition ? D : never; + +const TYPE_ID_PATTERN = /^[a-z0-9_-]+(\.[a-z0-9_-]+)+$/; +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"; + } +} + +/** + * Declares a notification type. The returned object is both what a plugin + * registers and what it passes to `publish`, so `data` is type-checked against + * the schema at every call site. + * + * @example + * ```ts + * export const commentNotification = buildNotificationType({ + * id: "blog.comment", + * version: 1, + * schema: z.object({ postId: z.number(), title: z.string() }), + * category: "social", + * label: "@vitnode/blog.notifications.comment.label", + * defaults: { inApp: true, email: "daily" }, + * present: ({ data, t }) => ({ + * title: t("@vitnode/blog.notifications.comment.title", { title: data.title }), + * target: `/blog/${data.postId}`, + * }), + * }); + * ``` + */ +export function buildNotificationType( + definition: NotificationTypeDefinition, +): NotificationTypeDefinition { + if (!TYPE_ID_PATTERN.test(definition.id) || definition.id.length > 100) { + 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 (!/^[a-z0-9_-]{1,50}$/.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; +} + +/** Refuses the same type id registered twice, by one plugin or by two. */ +export const validateNotificationTypes = ( + entries: readonly RegisteredNotificationType[], +): RegisteredNotificationType[] => { + 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); + } + + return [...entries]; +}; + +export const notificationTypeKey = (pluginId: string, type: string): string => + `${pluginId}:${type}`; + +export interface NotificationRegistry { + get: (type: string) => RegisteredNotificationType | undefined; + getSubject: (type: string) => RegisteredNotificationSubject | undefined; + list: RegisteredNotificationType[]; + subjects: RegisteredNotificationSubject[]; +} + +/** + * Builds the installation-wide registry. A type's `subjectType` that no plugin + * declared becomes a mute-only subject, so anything a notification is about + * can always be muted. + */ +export const createNotificationRegistry = ( + entries: readonly RegisteredNotificationType[], + subjects: readonly RegisteredNotificationSubject[] = [], +): NotificationRegistry => { + const list = validateNotificationTypes(entries); + const byId = new Map(list.map(entry => [entry.definition.id, entry])); + + const bySubject = new Map(); + for (const subject of subjects) { + const owner = bySubject.get(subject.definition.type); + if (owner) { + throw new NotificationRegistryError( + `Duplicate notification subject "${subject.definition.type}": registered by both "${owner.pluginId}" and "${subject.pluginId}".`, + ); + } + bySubject.set(subject.definition.type, subject); + } + for (const entry of list) { + const type = entry.definition.subjectType; + if (type && !bySubject.has(type)) { + bySubject.set(type, { + definition: { followable: false, type }, + pluginId: entry.pluginId, + }); + } + } + + return { + get: type => byId.get(type), + getSubject: type => bySubject.get(type), + list, + subjects: [...bySubject.values()], + }; +}; + +/** + * Parses stored event data with the type's current schema, upgrading it first + * when it was written by an older version. `null` means the data can no longer + * be shown - the inbox renders an "unavailable" placeholder instead. + */ +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..968cd4439 --- /dev/null +++ b/packages/vitnode/src/api/lib/notifications/safe.ts @@ -0,0 +1,65 @@ +const MAX_TARGET_LENGTH = 2048; +const MAX_TEXT_LENGTH = 500; +const MAX_ERROR_LENGTH = 500; + +/** + * Accepts only a path on this site. Rejects absolute URLs, protocol-relative + * `//host`, backslash tricks browsers normalise into `//`, and control + * characters - so a notification can never send someone off-site. + */ +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; + } +}; + +/** Plain, single-paragraph text: no markup survives, length is bounded. */ +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) + ); +}; + +/** + * Provider errors can echo the recipient address, API keys in URLs or whole + * SMTP transcripts. Diagnostics only need the gist. + */ +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..7f75eae10 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -21,6 +21,10 @@ 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, + NotificationSubjectDefinition, +} from "./notifications/registry"; import type { PermissionStaffConfig } from "./permission-staff"; import type { QueueTaskConfig } from "./queue"; import type { WebSocketConfig } from "./websocket"; @@ -29,6 +33,7 @@ import { validateSearchIndexers } from "../models/search"; import { checkPluginId } from "./check-plugin-id"; import { registerEditablePage, validateEditablePages } from "./editable-pages"; import { collectNavigationPresets } from "./navigation-presets"; +import { createNotificationRegistry } from "./notifications/registry"; import { applyModuleTags } from "./openapi-tags"; export type { ApiPluginContract }; @@ -48,6 +53,8 @@ export interface BuildPluginApiReturn< messages?: LocaleMessagesMap; modules: Modules; navigation?: NavigationPresetDeclaration[]; + notificationSubjects?: NotificationSubjectDefinition[]; + notificationTypes?: AnyNotificationTypeDefinition[]; openApiTags?: string[]; permissionStaff?: PermissionStaffConfig; pluginId: P; @@ -72,6 +79,8 @@ export function buildApiPlugin< messages, modules = [] as unknown as Modules, navigation, + notificationSubjects, + notificationTypes, permissionStaff, searchIndexers, }: { @@ -80,6 +89,10 @@ export function buildApiPlugin< messages?: LocaleMessagesMap; modules?: Modules; navigation?: NavigationPresetDeclaration[]; + /** Subjects users can follow or mute; see `buildNotificationSubject`. */ + notificationSubjects?: NotificationSubjectDefinition[]; + /** Notification types this plugin publishes; see `buildNotificationType`. */ + notificationTypes?: AnyNotificationTypeDefinition[]; permissionStaff?: PermissionStaffConfig; pluginId: P; searchIndexers?: SearchIndexer[]; @@ -109,6 +122,10 @@ export function buildApiPlugin< const cronJobs: BuildPluginApiReturn["cronJobs"] = []; const events: BuildPluginApiReturn["events"] = []; const indexers: SearchIndexer[] = [...(searchIndexers ?? [])]; + const types: AnyNotificationTypeDefinition[] = [...(notificationTypes ?? [])]; + const subjects: NotificationSubjectDefinition[] = [ + ...(notificationSubjects ?? []), + ]; const openApiTags: string[] = []; const queueTasks: BuildPluginApiReturn["queueTasks"] = []; const webSockets: BuildPluginApiReturn["webSockets"] = []; @@ -121,6 +138,8 @@ export function buildApiPlugin< contentTypes.push(...collectContentTypes(handler)); publicContentTypes.push(...collectPublicContentTypes(handler)); indexers.push(...collectSearchIndexers(handler)); + types.push(...collectModuleTree(handler, m => m.notificationTypes)); + subjects.push(...collectModuleTree(handler, m => m.notificationSubjects)); handler.cronJobs?.forEach(cron => { cronJobs.push({ ...cron, module: handler.name }); @@ -145,6 +164,13 @@ export function buildApiPlugin< validateSearchIndexers(indexers.map(indexer => ({ ...indexer, pluginId }))); + // Caught here too, so a plugin registering a type twice is named as the one + // that made the mistake; the global middleware re-checks across plugins. + createNotificationRegistry( + types.map(definition => ({ definition, pluginId })), + subjects.map(definition => ({ definition, pluginId })), + ); + const publishing = new Map(); for (const definition of [ ...registered.map(entry => entry.definition), @@ -170,6 +196,8 @@ export function buildApiPlugin< cronJobs, events, queueTasks, + notificationSubjects: subjects, + notificationTypes: types, searchIndexers: indexers, webSockets, // Every content type contributes can_view/can_create/can_edit/can_delete @@ -218,3 +246,14 @@ function collectSearchIndexers(module: BaseBuildModuleReturn): SearchIndexer[] { ...(module.modules ?? []).flatMap(collectSearchIndexers), ]; } + +/** Collects one list from a module and every module nested inside it. */ +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/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index cbc40ceb9..ad9dc2a36 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,7 @@ 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 { 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 +67,7 @@ import { type LoggerMiddlewareType, } from "../lib/logger-middleware"; import { collectNavigationPresets } from "../lib/navigation-presets"; +import { createNotificationRegistry } from "../lib/notifications/registry"; import { resolvePasskeysConfig } from "../lib/passkey-config"; import { normalizePermissionStaffModules } from "../lib/permission-staff"; @@ -149,6 +152,8 @@ export interface EnvVariablesVitNode { }; /** Every prebuilt main-menu item the installed plugins offer. */ navigation: NavigationPreset[]; + /** Every notification type and subject the installed plugins register. */ + notifications?: NotificationRegistry; permissionStaff: PermissionStaffCatalogEntry[]; /** Which personal-information fields this install offers. */ personalInformationFields: PersonalInformationFields; @@ -165,6 +170,7 @@ export interface EnvVariablesVitNode { i18n: I18nModel; ipAddress: string; log: LoggerMiddlewareType; + notifications: NotificationsModel; plugin: { id: string; }; @@ -367,6 +373,22 @@ export const globalMiddleware = ({ const navigationMetadata: NavigationPreset[] = collectNavigationPresets(plugins); + // Validated across *all* plugins: `buildApiPlugin` only sees its own types. + const notificationsMetadata = createNotificationRegistry( + plugins.flatMap(plugin => + (plugin.notificationTypes ?? []).map(definition => ({ + definition, + pluginId: plugin.pluginId, + })), + ), + plugins.flatMap(plugin => + (plugin.notificationSubjects ?? []).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,7 @@ export const globalMiddleware = ({ webSockets: webSocketsMetadata, permissionStaff: permissionStaffMetadata, navigation: navigationMetadata, + notifications: notificationsMetadata, contentModels: contentModelsMetadata, contentRevalidateOrigins: content?.revalidateOrigins, contentTypes: contentTypesMetadata, @@ -475,6 +499,10 @@ export const globalMiddleware = ({ c.set("log", loggerMiddleware(c)); await next(); + + // By now the handler returned, so any transaction it published inside has + // committed or rolled back. Delivery starts without delaying the response. + c.get("notifications").flushAfterResponse(); }; }; diff --git a/packages/vitnode/src/api/models/email.ts b/packages/vitnode/src/api/models/email.ts index ccbd008bf..d35403f0a 100644 --- a/packages/vitnode/src/api/models/email.ts +++ b/packages/vitnode/src/api/models/email.ts @@ -29,12 +29,27 @@ interface EmailModelSendArgsWithEmail { export interface EmailApiPlugin { sendEmail: (args: { html: string; + /** + * Stable per logical email. Adapters whose provider deduplicates on a key + * should forward it, so a retry after an unconfirmed send is not delivered + * twice. Optional - providers without the feature can ignore it. + */ + idempotencyKey?: string; metadata: ContextVariableMap["core"]["metadata"]; replyTo?: string; subject: string; text: string; to: string; - }) => Promise; + // `void` on purpose: adapters written before this returned nothing, and + // must keep compiling. + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + }) => Promise; +} + +/** What a provider reports back, when it reports anything. */ +export interface EmailSendResult { + /** The provider's id for the accepted message, for delivery diagnostics. */ + id?: string; } export type EmailModelSendArgs = { @@ -131,17 +146,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 result = 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 result ?? {}; } async send(args: EmailModelSendArgs): Promise { 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..98541ce84 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/admin.ts @@ -0,0 +1,371 @@ +import { and, count, desc, eq, inArray, like, lt, sql } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import type { + NotificationGlobalSettings, + NotificationTypePolicy, +} from "@/api/lib/notifications/preferences"; + +import { isNotificationEmailAvailable } from "@/api/lib/notifications/preferences"; +import { core_cron } from "@/database/cron"; +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 { isCronStale } from "@/lib/api/is-cron-stale"; + +import type { NotificationsContext } from "./shared"; + +import { dispatchEmailDrain } from "./email-queue"; +import { reconcileNotificationCounts } from "./inbox"; +import { + getNotificationRegistry, + GLOBAL_SETTINGS_KEY, + isEmailConfigured, + loadNotificationSettings, + normalizeGlobalSettings, + NOTIFICATIONS_PLUGIN_ID, + QUEUE_NOTIFICATIONS_CLEANUP, + typeSettingsKey, +} from "./shared"; + +const countBy = (rows: { count: number; key: K }[]) => + Object.fromEntries(rows.map(row => [row.key, row.count])) as Partial< + Record + >; + +/** + * Everything the AdminCP overview shows. Only aggregates and type metadata - + * no notification content and no recipient data. + */ +export const getNotificationsOverview = async (c: NotificationsContext) => { + const db = c.get("db"); + const core = c.get("core"); + const settings = await loadNotificationSettings(db); + const emailConfigured = isEmailConfigured(c, settings); + + const [queueRows, deliveryRows, eventRows, [oldestPending], [cron]] = + await Promise.all([ + 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), + db + .select({ + lastActivity: sql< + null | string + >`max(coalesce(${core_cron.lastRun}, ${core_cron.createdAt}))`, + }) + .from(core_cron), + ]); + + return { + email: { + adapterConfigured: !!core.email?.adapter, + enabled: settings.global.emailEnabled, + }, + health: { + cronActive: core.hasCronAdapter, + cronStale: isCronStale( + cron?.lastActivity ? new Date(cron.lastActivity) : null, + ), + deliveries: countBy(deliveryRows), + events: countBy(eventRows), + oldestPendingEventAt: oldestPending?.createdAt ?? null, + queue: countBy(queueRows), + }, + settings: settings.global, + 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: definition.label, + mandatory: !!definition.mandatory, + pluginId, + policy: { + allowEmail: policy.allowEmail ?? true, + email: policy.email ?? null, + enabled: policy.enabled ?? true, + inApp: policy.inApp ?? null, + }, + version: definition.version, + }; + }), + }; +}; + +const upsertSetting = 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, + }); +}; + +export const updateNotificationGlobalSettings = async ( + c: NotificationsContext, + patch: Partial, +): Promise => { + const { global } = await loadNotificationSettings(c.get("db")); + const next = normalizeGlobalSettings({ ...global, ...patch }); + await upsertSetting(c, GLOBAL_SETTINGS_KEY, { ...next }); + + return next; +}; + +export const updateNotificationTypePolicy = async ( + c: NotificationsContext, + typeId: string, + policy: NotificationTypePolicy, +): Promise => { + const registered = getNotificationRegistry(c).get(typeId); + if (!registered) throw new HTTPException(404); + if (policy.email && policy.email !== "none" && !registered.definition.email) { + throw new HTTPException(400, { + message: "This type has no email channel.", + }); + } + + await upsertSetting(c, typeSettingsKey(typeId), { + ...(policy.allowEmail === undefined + ? {} + : { allowEmail: policy.allowEmail }), + ...(policy.email === undefined ? {} : { email: policy.email }), + ...(policy.enabled === undefined ? {} : { enabled: policy.enabled }), + ...(policy.inApp === undefined ? {} : { inApp: policy.inApp }), + }); +}; + +/** + * Delivery records for diagnosis. Deliberately leaves out addresses, subjects + * and content: the type, mode, attempts and a sanitized error are what an + * admin needs to find the fault, and the recipient stays a user id. + */ +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, + }; +}; + +/** + * Puts a failed delivery back in line with a fresh set of attempts. It keeps + * its row and idempotency key, so the provider sees the same key and the + * same receipts are rendered - a retry can never fan out into a second email. + */ +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); +}; + +/** Queues a test notification email to the signed-in admin - nobody else. */ +export const queueNotificationTestEmail = async ( + c: NotificationsContext, + adminUserId: number, +): Promise<{ deliveryId: number }> => { + const settings = await loadNotificationSettings(c.get("db")); + if (!isEmailConfigured(c, settings)) { + 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; + +/** Users whose stored unread count disagrees with their inbox. */ +export 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( + sql`${core_notifications.archivedAt} IS NULL AND ${core_notifications.readSeq} < ${core_notifications.activitySeq}`, + ) + .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 }; +}; + +export const queueNotificationCleanup = async (c: NotificationsContext) => { + await c.get("queue").dispatch({ + name: QUEUE_NOTIFICATIONS_CLEANUP, + pluginId: NOTIFICATIONS_PLUGIN_ID, + }); +}; 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..0f9ef053c --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/email-queue.ts @@ -0,0 +1,39 @@ +import { and, eq, inArray } from "drizzle-orm"; + +import { core_queue } from "@/database/queue"; + +import type { NotificationsContext, NotificationsDb } from "./shared"; + +import { NOTIFICATIONS_PLUGIN_ID, QUEUE_NOTIFICATIONS_EMAIL } from "./shared"; + +/** + * Makes sure one email drain is queued. Drains are idempotent - each claims + * deliveries with `SKIP LOCKED` - so a second one racing in is harmless; this + * only keeps a burst from queueing hundreds of them. + */ +export const dispatchEmailDrain = async ( + c: NotificationsContext, + tx?: NotificationsDb, + availableAt?: Date, +): Promise => { + const db = tx ?? c.get("db"); + const [queued] = await db + .select({ id: core_queue.id }) + .from(core_queue) + .where( + and( + eq(core_queue.pluginId, NOTIFICATIONS_PLUGIN_ID), + eq(core_queue.name, QUEUE_NOTIFICATIONS_EMAIL), + inArray(core_queue.status, ["pending"]), + ), + ) + .limit(1); + if (queued) 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..f8497a17b --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/email.integration.test.ts @@ -0,0 +1,307 @@ +// @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_notification_user_state, + core_notifications, +} from "@/database/notifications"; +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); + + // Not due yet: a drain right now sends nothing. + 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") + .insert(core_notification_user_state) + .values({ digestHour: 8, timeZone: "Europe/Warsaw", userId: user }) + .onConflictDoUpdate({ + set: { digestHour: 8, timeZone: "Europe/Warsaw" }, + target: core_notification_user_state.userId, + }); + + 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(); + + // Europe moved its clocks forward on 29 March 2026: 08:00 in Warsaw was + // 07:00 UTC on the 28th and 06:00 UTC on the 29th. + 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 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: new Date("2026-03-29T06:00:00Z"), + periodStart: new Date("2026-03-28T07:00:00Z"), + status: "sent", + }); + expect( + sentTo(user) + .at(-1) + ?.entries.map(entry => entry.title), + ).toEqual(["Saturday news"]); + + // The next period picks up only what the first did not claim. + 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"]); + + // Nothing is left to send, and running again sends nothing. + 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); + + // A new event after the claim must not slip into the retried digest. + 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..e46b6504b --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/email.ts @@ -0,0 +1,814 @@ +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 { 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_subscriptions, + 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 { 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 mutedKey = (userId: number, type: string, id: string) => + `${userId}|${type}|${id}`; + +const loadMuted = async ( + c: NotificationsContext, + userIds: number[], +): Promise> => { + if (userIds.length === 0) return new Set(); + const rows = await c + .get("db") + .select({ + subjectId: core_notification_subscriptions.subjectId, + subjectType: core_notification_subscriptions.subjectType, + userId: core_notification_subscriptions.userId, + }) + .from(core_notification_subscriptions) + .where( + and( + inArray(core_notification_subscriptions.userId, userIds), + eq(core_notification_subscriptions.state, "muted"), + ), + ); + + return new Set( + rows.map(row => mutedKey(row.userId, row.subjectType, row.subjectId)), + ); +}; + +const isMuted = ( + muted: Set, + userId: number, + event: Pick, +) => + !!event.subjectType && + !!event.subjectId && + muted.has(mutedKey(userId, event.subjectType, event.subjectId)); + +/** The email mode the user would get for this event right now. */ +const currentEmailMode = ({ + c, + event, + muted, + preferences, + settings, + userId, +}: { + c: NotificationsContext; + event: NotificationEventRow; + muted: Set; + preferences: Record; + settings: NotificationSettingsSnapshot; + userId: number; +}): NotificationEmailMode => { + const resolved = resolveEvent(c, event); + if (!resolved) return "none"; + + return resolveNotificationChannels({ + definition: resolved.registered.definition, + emailConfigured: isEmailConfigured(c, settings), + muted: isMuted(muted, userId, event), + 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; +} + +/** Drops receipts from email for good, or hands them back to the planner. */ +const settleReceipts = async ( + c: NotificationsContext, + userId: number, + { dropped, released }: { dropped: number[]; released: 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 (released.length > 0) { + await db + .update(core_notification_receipts) + .set({ emailDeliveryId: null }) + .where( + and( + eq(core_notification_receipts.userId, userId), + inArray(core_notification_receipts.eventId, released), + ), + ); + } +}; + +const isAlreadyRead = 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 items = 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 byId = new Map(items.map(item => [item.id, item])); + + return new Set( + receipts + .filter(receipt => { + if (receipt.notificationId === null || receipt.seq === null) { + return false; + } + const item = byId.get(receipt.notificationId); + + // Read past this event, archived, or removed: the user dealt with it. + if (!item) return true; + + return item.archivedAt !== null || item.readSeq >= receipt.seq; + }) + .map(receipt => receipt.eventId), + ); +}; + +/** + * Re-checks every claimed receipt against current preferences, mutes, access + * and read state, then renders what is left. Nothing is decided from the + * state at fan-out time - a delayed email reflects the user's settings now. + */ +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, settings)) { + 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: `vitnode-notification-${delivery.idempotencyKey}`, + }); + + 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 muted = await loadMuted(c, [user.id]); + const read = await isAlreadyRead(c, claimed); + const preferences = user.preferences ?? {}; + const dropped: number[] = []; + const released: number[] = []; + const kept: { event: NotificationEventRow; receipt: ClaimedReceipt }[] = []; + + for (const receipt of claimed) { + const mode = currentEmailMode({ + c, + event: receipt.event, + muted, + preferences, + settings, + userId: user.id, + }); + + if (mode !== delivery.mode) { + // The user switched modes since this was queued; the planner routes it. + (mode === "none" ? dropped : released).push(receipt.eventId); + } else if (read.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, released }); + 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, + }); + + // Same key on every attempt: a provider that deduplicates drops the second + // send if the first was accepted but its answer never reached us. + const sent = await emailModel.deliver(built, { + idempotencyKey: `vitnode-notification-${delivery.idempotencyKey}`, + }); + + 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); + } + }), + ); +}; + +/** + * Sends up to `emailBatchSize` due deliveries, `emailConcurrency` at a time. + * In-app delivery never waits on this: email runs in its own queue task, so a + * slow or failing provider only delays email. + */ +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); + + 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(settings.global.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, + settings.global.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; +}; + +/** + * A worker that died mid-send leaves a delivery in `sending`. After a grace + * period it goes back to `pending`. The provider may already have accepted the + * message - with an idempotency-capable provider the retry is dropped, with + * plain SMTP it can arrive twice. That window is the one unavoidable + * uncertainty; nothing here claims exactly-once email. + */ +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; +} + +/** + * Turns pending email receipts into deliveries: one digest per user and + * period, or an immediate email for receipts whose user switched to immediate. + * A digest delivery's key is the user, mode and local date of its period, so a + * period is claimed once however often this runs; receipts it claimed are + * never offered to another digest. + */ +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, muted] = await Promise.all([ + db + .select({ + digestHour: core_notification_user_state.digestHour, + digestWeekday: core_notification_user_state.digestWeekday, + languageTimeZone: core_languages.timezone, + preferences: core_notification_user_state.preferences, + timeZone: core_notification_user_state.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), + ), + ), + loadMuted(c, userIds), + ]); + + 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, + muted, + preferences: profile.preferences ?? {}, + settings, + userId: receipt.userId, + }) + : "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: profile.digestHour ?? 8, + mode, + now, + timeZone: profile.timeZone ?? profile.languageTimeZone ?? "UTC", + weekday: profile.digestWeekday ?? 1, + }); + // Not due yet: it belongs to the period still running. + if (receipt.createdAt >= period.end) 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 list = [...plans.values()]; + if (list.length === 0) return 0; + + const inserted = await tx + .insert(core_notification_deliveries) + .values( + list.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 claims = inserted.flatMap(row => { + const plan = plans.get(row.idempotencyKey); + + return (plan?.eventIds ?? []).map(eventId => [ + sql`${eventId}::bigint`, + sql`${plan?.userId ?? 0}::integer`, + sql`${row.id}::bigint`, + ]); + }); + + if (claims.length > 0) { + // `IS NULL` guards against a concurrent planner having claimed first. + 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 + `); + } + + // An immediate delivery that already existed means this event already + // had its one immediate email; it is not sent again. + const conflicted = list.filter( + plan => + plan.mode === "immediate" && + !inserted.some(row => row.idempotencyKey === plan.idempotencyKey), + ); + if (conflicted.length > 0) { + await tx.execute(sql` + UPDATE ${core_notification_receipts} AS r SET "emailPending" = false + FROM (VALUES ${valuesList( + conflicted.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..f4ed4f280 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/fanout.integration.test.ts @@ -0,0 +1,253 @@ +// @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(); + // A retry of the whole event after it completed changes nothing. + 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(); + + // Simulate a worker that committed the batch but died before recording + // completion: rewind the event's cursor and process it again. + 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..a5140fd2b --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts @@ -0,0 +1,192 @@ +// @vitest-environment node +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, expect, it } from "vitest"; +import { z } from "zod"; + +import { + buildNotificationSubject, + buildNotificationType, +} from "@/api/lib/notifications/registry"; +import { + core_notification_deliveries, + core_notification_events, + core_notification_receipts, + core_notification_settings, + core_notification_subscriptions, + 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 }), +}); + +const topicSubject = buildNotificationSubject({ type: "test.topic" }); + +/** + * The 1,000-recipient case: half the audience named by the plugin, the rest + * streamed from followers of a subject, with overlap between the two. Timings + * are printed for the record, never asserted - they depend on the machine. + */ +describePostgres("notification fan-out with 1,000 recipients", () => { + let h: NotificationsHarness; + let users: number[] = []; + + beforeAll(async () => { + h = await createNotificationsHarness({ + subjects: [topicSubject], + types: [announcementType], + }); + users = await h.createUsers(RECIPIENTS + 1); + await h.c + .get("db") + .insert(core_notification_settings) + .values({ key: "global", value: { fanoutBatchSize: BATCH } }); + + // Users 1-600 follow the topic; 400-1000 are named explicitly (overlap 400-600). + await h.c + .get("db") + .insert(core_notification_subscriptions) + .values( + users.slice(1, 601).map(userId => ({ + state: "following" as const, + subjectId: "1", + subjectType: "test.topic", + userId, + })), + ); + // One follower muted the topic: they must get nothing. + await h.c + .get("db") + .insert(core_notification_subscriptions) + .values({ + state: "muted", + subjectId: "1", + subjectType: "test.topic", + userId: users[700], + }) + .onConflictDoNothing(); + }, 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" }, + followersOf: [{ id: 1, type: "test.topic" }], + idempotencyKey: "announcement-1", + recipients: [actor, ...users.slice(400)], + subject: { id: 1, type: "test.topic" }, + type: announcementType, + }); + const publishMs = performance.now() - started; + + // Two workers race on the same event; the event row lock serializes them. + const fork = h.forkContext(); + const fanoutStarted = performance.now(); + await Promise.all([ + processNotificationEvent(h.c, eventId), + processNotificationEvent(fork, eventId), + ]); + const fanoutMs = performance.now() - fanoutStarted; + const fanoutQueries = h.queryCount() - queriesBefore; + + const expected = RECIPIENTS - 1; // everyone but the muted follower; the actor is skipped + 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", + }); + + // Repeated processing: rewind the cursors as a crashed worker would leave + // them, run it again twice, and nothing may change. + await h.c + .get("db") + .update(core_notification_events) + .set({ + followerSubjectCursor: 0, + followerUserCursor: 0, + 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); // the muted follower holds a 0 + + const deliveries = await h.c + .get("db") + .select({ count: sql`count(*)::integer` }) + .from(core_notification_deliveries); + expect(deliveries[0]?.count).toBe(expected); + + // Bounded work: the access check ran once per batch, not per recipient, + // and the whole fan-out took a fixed number of queries per batch. + const batches = Math.ceil(600 / BATCH) + Math.ceil(601 / BATCH); + expect(accessCalls).toBeLessThanOrEqual(batches * 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..7df59e83d --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/fanout.ts @@ -0,0 +1,529 @@ +import type { SQL } from "drizzle-orm"; + +import { and, asc, eq, gt, 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_subscriptions, + core_notification_user_state, + core_notifications, +} from "@/database/notifications"; +import { core_users } from "@/database/users"; + +import type { + NotificationsContext, + NotificationsDb, + NotificationSettingsSnapshot, +} from "./shared"; + +import { dispatchEmailDrain } from "./email-queue"; +import { + getNotificationRegistry, + isEmailConfigured, + loadNotificationSettings, + sendNotificationStates, +} from "./shared"; + +type EventRow = typeof core_notification_events.$inferSelect; +type StateUpdate = NotificationStateMessage & { userId: number }; + +/** `(a, b), (c, d)` - rows for a `VALUES` list. */ +export const valuesList = (rows: SQL[][]): SQL => + sql.join( + rows.map(row => sql`(${sql.join(row, sql`, `)})`), + sql`, `, + ); + +/** + * Creates the user-state rows a batch needs and locks them, always in user id + * order. Every inbox write takes these locks first, so two writers touching + * the same users can never deadlock, and a user's inbox changes serialize. + */ +export const lockUserStates = async ( + tx: NotificationsDb, + userIds: number[], +) => { + const sorted = [...new Set(userIds)].sort((a, b) => a - b); + if (sorted.length === 0) return []; + + await tx + .insert(core_notification_user_state) + .values(sorted.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, sorted)) + .orderBy(asc(core_notification_user_state.userId)) + .for("update"); +}; + +/** + * Applies unread-count deltas to rows already locked by `lockUserStates`, + * bumping each user's revision exactly once. + */ +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 { + cursors: Pick< + EventRow, + "followerSubjectCursor" | "followerUserCursor" | "recipientCursor" + >; + done: boolean; + userIds: number[]; +} + +const nextCandidates = async ( + tx: NotificationsDb, + event: EventRow, + batchSize: number, +): Promise => { + if (event.recipientCursor < event.recipientIds.length) { + const userIds = event.recipientIds.slice( + event.recipientCursor, + event.recipientCursor + batchSize, + ); + const recipientCursor = event.recipientCursor + userIds.length; + + return { + cursors: { ...event, recipientCursor }, + done: + recipientCursor >= event.recipientIds.length && + event.followersOf.length === 0, + userIds, + }; + } + + const subject = event.followersOf[event.followerSubjectCursor]; + if (!subject) return { cursors: event, done: true, userIds: [] }; + + const rows = await tx + .select({ userId: core_notification_subscriptions.userId }) + .from(core_notification_subscriptions) + .where( + and( + eq(core_notification_subscriptions.subjectType, subject.type), + eq(core_notification_subscriptions.subjectId, subject.id), + eq(core_notification_subscriptions.state, "following"), + gt(core_notification_subscriptions.userId, event.followerUserCursor), + ), + ) + .orderBy(asc(core_notification_subscriptions.userId)) + .limit(batchSize); + + const userIds = rows.map(row => row.userId); + const exhausted = userIds.length < batchSize; + const followerSubjectCursor = + event.followerSubjectCursor + (exhausted ? 1 : 0); + + return { + cursors: { + followerSubjectCursor, + followerUserCursor: exhausted ? 0 : (userIds.at(-1) ?? 0), + recipientCursor: event.recipientCursor, + }, + done: exhausted && followerSubjectCursor >= event.followersOf.length, + 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)); +}; + +/** Narrows candidates to who may and wants to receive this event. */ +const filterRecipients = async ({ + c, + candidates, + data, + event, + registered, +}: { + c: NotificationsContext; + candidates: number[]; + data: unknown; + event: EventRow; + registered: RegisteredNotificationType; +}) => { + const notSelf = candidates.filter( + id => event.allowSelf || id !== event.actorId, + ); + if (notSelf.length === 0) return { eligible: [], muted: new Set() }; + + const db = c.get("db"); + const existing = await db + .select({ id: core_users.id }) + .from(core_users) + .where(inArray(core_users.id, notSelf)); + let eligible = existing.map(row => row.id); + + const subject = + event.subjectType && event.subjectId + ? { id: event.subjectId, type: event.subjectType } + : null; + + const muted = new Set(); + if (subject && eligible.length > 0) { + const rows = await db + .select({ userId: core_notification_subscriptions.userId }) + .from(core_notification_subscriptions) + .where( + and( + eq(core_notification_subscriptions.subjectType, subject.type), + eq(core_notification_subscriptions.subjectId, subject.id), + eq(core_notification_subscriptions.state, "muted"), + inArray(core_notification_subscriptions.userId, eligible), + ), + ); + rows.forEach(row => muted.add(row.userId)); + } + + const { access } = registered.definition; + if (access && eligible.length > 0) { + const allowed = new Set( + await access({ c, data, subject, userIds: [...eligible] }), + ); + eligible = eligible.filter(id => allowed.has(id)); + } + + return { eligible, muted }; +}; + +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 = await nextCandidates( + tx, + event, + settings.global.fanoutBatchSize, + ); + const { eligible, muted } = 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, settings); + const policy = settings.policies.get(definition.id); + const channels = new Map( + locked.map(row => [ + row.userId, + resolveNotificationChannels({ + definition, + emailConfigured, + muted: muted.has(row.userId), + 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 }); + + // Only users whose receipt is new go further: a retry of a batch that + // already committed finds every receipt in place and changes nothing. + const fresh = inserted.map(row => row.userId).sort((a, b) => a - b); + const inApp = fresh.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 = fresh.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({ + ...candidates.cursors, + completedAt: candidates.done ? now : null, + deliveredCount: sql`${core_notification_events.deliveredCount} + ${delivered}`, + lastError: null, + status: candidates.done ? "completed" : "processing", + }) + .where(eq(core_notification_events.id, event.id)); + + return { delivered, done: candidates.done, states }; +}; + +export interface FanoutResult { + batches: number; + delivered: number; + done: boolean; +} + +/** + * Delivers an event batch by batch, each in its own short transaction that + * also saves the cursor - a crash or retry resumes after the last committed + * batch. Stops after `timeBudgetMs` so one huge audience cannot hold the + * queue worker; the caller re-queues the rest. + */ +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 result: FanoutResult = { batches: 0, delivered: 0, done: false }; + + while (!result.done) { + const batch = await db.transaction( + async tx => await processBatch(c, tx, eventId, settings), + ); + sendNotificationStates(c, batch.states); + + result.batches += 1; + result.delivered += batch.delivered; + result.done = batch.done; + + if (!result.done && Date.now() >= deadline) break; + } + + return result; +}; 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..91877ed13 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/inbox.integration.test.ts @@ -0,0 +1,434 @@ +// @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}` }), +}); + +/** Turns the access check on and off at runtime, like revoking a permission. */ +let revoked = 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 => !revoked.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); + + // Repeating the read never decrements again. + 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); + + // Another reply joins the group after the user saw it, before the click. + 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); + + // Read with the new boundary: now it is read, and new activity re-opens it once. + 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); + // Age the first group by two windows. + 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); + + // Hold the user's lock in another connection as if a fan-out were mid-way: + // mark-all must wait for it and then include what it committed. + const other = h.forkContext(); + let release: () => void = () => undefined; + const held = new Promise(resolve => { + release = resolve; + }); + const blocker = other.get("db").transaction(async tx => { + await tx + .select() + .from(core_notification_user_state) + .where(eq(core_notification_user_state.userId, user)) + .for("update"); + await held; + }); + + const markAll = markAllNotificationsRead(h.c, { userId: user }); + await new Promise(resolve => setTimeout(resolve, 100)); + release(); + await blocker; + const result = await markAll; + + expect(result.marked).toBe(2); + expect(result.unread).toBe(0); + + // Delivered after the boundary: stays unread. + 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, + }), + ), + ); + + // Deliver concurrently from three connections while reading. + 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", + }); + + revoked = new Set([alice]); + const hidden = await listNotifications(h.c, { limit: 20, userId: alice }); + revoked = new Set(); + + // Still listed - so it can be cleared - but with nothing private in it. + 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..2311dfcf1 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/inbox.ts @@ -0,0 +1,555 @@ +import { and, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +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 { + /** Pass back to `markRead` - the read boundary of what was shown. */ + activitySeq: number; + /** Distinct actors across every grouped event. */ + actorCount: number; + actors: NotificationActor[]; + /** `false` when the content is gone, access was lost or the plugin is uninstalled. */ + 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; +} + +interface Cursor { + id: number; + t: number; +} + +const encodeCursor = (cursor: Cursor): string => + Buffer.from(JSON.stringify(cursor)).toString("base64url"); + +const decodeCursor = (raw: string): Cursor => { + try { + const parsed: unknown = JSON.parse( + Buffer.from(raw, "base64url").toString("utf8"), + ); + if ( + typeof parsed === "object" && + parsed !== null && + "id" in parsed && + "t" in parsed && + Number.isSafeInteger(parsed.id) && + Number.isSafeInteger(parsed.t) + ) { + return parsed as Cursor; + } + } catch { + // fall through + } + + throw new HTTPException(400, { message: "Invalid pagination cursor." }); +}; + +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 }; +}; + +/** + * One page of a user's inbox, newest activity first. Every item is re-checked + * against its type before anything is returned: an item whose content is gone, + * whose access was revoked or whose plugin is uninstalled comes back as an + * `available: false` placeholder - no title, link or actors - so it can still + * be read or archived instead of silently inflating the count. + */ +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 afterDate = after ? new Date(after.t) : 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 && afterDate + ? or( + lt(core_notifications.lastActivityAt, afterDate), + and( + eq(core_notifications.lastActivityAt, afterDate), + 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 isCounted = ( + item: Pick, +) => item.archivedAt === null && item.readSeq < item.activitySeq; + +/** + * Runs one item change under the user's state lock and applies the change to + * the canonical count in the same transaction. Returns the new state, which is + * pushed to the user's connections only after commit. + */ +const changeItem = async ( + c: NotificationsContext, + { + change, + notificationId, + reason, + userId, + }: { + change: (item: ItemRow, now: Date) => null | Partial; + notificationId: number; + reason: NotificationStateMessage["reason"]; + userId: number; + }, +): Promise => { + const result = await c.get("db").transaction(async tx => { + await lockUserStates(tx, [userId]); + + // Scoped by user: someone else's id is indistinguishable from a missing one. + 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 ?? (await getNotificationState(tx, userId)), + update, + }; + }); + + if (result.update) sendNotificationStates(c, [result.update]); + + return { revision: result.state.revision, unread: result.state.unread }; +}; + +/** + * Marks an item read up to `throughSeq` - the `activitySeq` the user saw. A + * grouped event that arrived after the item was rendered pushes `activitySeq` + * past that boundary, so the item correctly stays unread. + */ +export const markNotificationRead = async ( + c: NotificationsContext, + args: { notificationId: number; throughSeq?: number; userId: number }, +): Promise => + await changeItem(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 changeItem(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 changeItem(c, { + change: (item, now) => (item.archivedAt ? null : { archivedAt: now }), + notificationId: args.notificationId, + reason: "archived", + userId: args.userId, + }); + +/** + * Marks everything read in one statement, after taking the user's state lock. + * That lock is the boundary: a fan-out that committed before it is included, + * one still running waits for it and then adds its item as unread. + */ +export const markAllNotificationsRead = async ( + c: NotificationsContext, + { category, userId }: { category?: string; userId: number }, +): Promise => { + const result = await c.get("db").transaction(async tx => { + await lockUserStates(tx, [userId]); + const now = new Date(); + + const marked = 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 (marked.length === 0) { + return { + marked: 0, + state: await getNotificationState(tx, userId), + update: null, + }; + } + + const [update] = await applyUnreadDeltas( + tx, + new Map([[userId, -marked.length]]), + "read_all", + ); + + return { marked: marked.length, state: update, update }; + }); + + if (result.update) sendNotificationStates(c, [result.update]); + + return { + marked: result.marked, + revision: result.state.revision, + unread: result.state.unread, + }; +}; + +/** + * Recounts unread items for each user from scratch and fixes the stored count + * where it drifted. For diagnostics and recovery, not the hot path. + */ +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 += 200) { + const chunk = userIds.slice(index, index + 200); + 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 }; +}; + +/** + * Removes inbox items - when access is revoked, content is deleted or by + * retention - and takes their unread share off each owner's count in the same + * transaction. + */ +export const removeNotificationItems = async ( + c: NotificationsContext, + { + reason = "removed", + where, + }: { + reason?: NotificationStateMessage["reason"]; + where: ReturnType; + }, + { batchSize = 500 }: { batchSize?: number } = {}, +): 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(Math.max(1, Math.floor(batchSize / 10))); + if (targets.length === 0) break; + + const userIds = targets.map(row => row.userId); + const result = 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, reason), + }; + }); + + sendNotificationStates(c, result.updates); + removed += result.count; + if (result.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..789047ad3 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/index.ts @@ -0,0 +1,136 @@ +import { and, eq, inArray } from "drizzle-orm"; + +import type { NotificationSubject } from "@/lib/notifications/types"; + +import { core_notifications } from "@/database/notifications"; + +import type { + PublishNotificationArgs, + PublishNotificationResult, +} from "./publish"; +import type { NotificationsContext } from "./shared"; +import type { NotificationSubscriptionState } from "./subscriptions"; + +import { getNotificationState, removeNotificationItems } from "./inbox"; +import { publishNotification } from "./publish"; +import { runQueuedTasksNow } from "./run-now"; +import { getSubscriptionState, setSubscriptionState } from "./subscriptions"; + +export type { PublishNotificationArgs, PublishNotificationResult }; + +/** + * The Notifications Center on the API context: `c.get("notifications")`. + * Plugins decide *who* might care; this decides who actually receives what, + * and delivers it. + */ +export class NotificationsModel { + constructor(c: NotificationsContext) { + this.c = c; + } + + protected readonly c: NotificationsContext; + private readonly queued: number[] = []; + + /** + * Starts delivering what this request published as soon as the response is + * on its way, rather than on the next queue tick. Claims go through the + * queue's own row locks, so this never double-processes; anything it cannot + * claim (rolled back, already taken) is left to the worker. + */ + flushAfterResponse(): void { + const ids = this.queued.splice(0); + if (ids.length === 0) return; + + void runQueuedTasksNow(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 follow(userId: number, subject: NotificationSubject): Promise { + await setSubscriptionState(this.c, userId, subject, "following"); + } + + async mute(userId: number, subject: NotificationSubject): Promise { + await setSubscriptionState(this.c, userId, subject, "muted"); + } + + /** + * Durably records an event and queues its delivery. With `tx` it joins the + * producer's transaction, so a rolled-back write never notifies anyone and a + * committed one always does, even if the process dies right after. + * + * @example + * ```ts + * await c.get("db").transaction(async tx => { + * const [comment] = await tx.insert(comments).values(values).returning(); + * await c.get("notifications").publish({ + * type: commentNotification, + * tx, + * recipients: [post.authorId], + * subject: { type: "blog.post", id: post.id }, + * data: { postId: post.id, title: post.title }, + * idempotencyKey: `comment:${comment.id}`, + * }); + * }); + * ``` + */ + async publish( + args: PublishNotificationArgs, + ): Promise { + return await publishNotification(this.c, args, queueId => + this.queued.push(queueId), + ); + } + + /** + * Removes inbox items about a subject - after the content is deleted or a + * group of users lost access - keeping every owner's unread count right. + * Narrow it with `type` and `userIds`; with neither, every recipient's item + * about the subject goes. + */ + 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); + } + + async subscription( + userId: number, + subject: NotificationSubject, + ): Promise { + return await getSubscriptionState(this.c, userId, subject); + } + + async unfollow(userId: number, subject: NotificationSubject): Promise { + if ((await this.subscription(userId, subject)) === "following") { + await setSubscriptionState(this.c, userId, subject, "none"); + } + } + + async unmute(userId: number, subject: NotificationSubject): Promise { + if ((await this.subscription(userId, subject)) === "muted") { + await setSubscriptionState(this.c, userId, subject, "none"); + } + } +} 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..ac076c5bc --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/maintenance.ts @@ -0,0 +1,200 @@ +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 } from "./shared"; + +import { planNotificationDigests, reclaimStaleDeliveries } from "./email"; +import { dispatchEmailDrain } from "./email-queue"; +import { removeNotificationItems } from "./inbox"; +import { + loadNotificationSettings, + NOTIFICATIONS_PLUGIN_ID, + QUEUE_NOTIFICATIONS_FANOUT, +} from "./shared"; + +const DAY = 24 * 60 * 60 * 1000; +const STALLED_EVENT_MINUTES = 10; +/** Digest receipts nobody claimed in this long are dropped from email. */ +const PENDING_EMAIL_MAX_DAYS = 14; +const DELETE_BATCH = 1000; + +/** + * Removes inbox items, events and delivery records older than the retention + * period. Items go through `removeNotificationItems`, so unread ones come off + * their owner's count in the same transaction; events are only deleted once + * no inbox item points at them. + */ +export const runNotificationCleanup = async ( + c: NotificationsContext, + { now = new Date() }: { now?: Date } = {}, +): Promise<{ deliveries: number; events: number; items: number }> => { + const db = c.get("db"); + const { global } = await loadNotificationSettings(db); + const cutoff = new Date(now.getTime() - global.retentionDays * DAY); + + const items = await removeNotificationItems(c, { + where: lt(core_notifications.lastActivityAt, cutoff), + }); + + 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( + lt(core_notification_events.createdAt, cutoff), + 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; + } + + 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() - PENDING_EMAIL_MAX_DAYS * DAY), + ), + ), + ); + + return { deliveries, events, items }; +}; + +/** + * Re-queues fan-out for events left unfinished with no task to finish them - + * e.g. their task ran out of attempts while the database was down. + */ +export 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; +}; + +/** The periodic tick: recovery first, then digests, then make sure email drains. */ +export const runNotificationSchedule = async ( + c: NotificationsContext, + { now = new Date() }: { now?: Date } = {}, +) => { + 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..da747b535 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/preferences.ts @@ -0,0 +1,210 @@ +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 { isValidTimeZone } from "@/api/lib/notifications/digest-period"; +import { + isNotificationEmailAvailable, + 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; + description: null | string; + /** The modes this type may use here - empty when email is unavailable. */ + emailModes: NotificationEmailMode[]; + id: string; + label: string; + mandatory: boolean; + pluginId: string; + /** What the user gets now, after defaults and installation policy. */ + value: { email: NotificationEmailMode; inApp: boolean }; +} + +export interface NotificationPreferencesView { + digestHour: number; + digestWeekday: number; + timeZone: null | string; + types: NotificationTypePreferenceView[]; +} + +const translateOr = ( + t: Awaited>>["t"], + keys: string[], + fallback: string, +): string => { + for (const key of keys) { + if (t.has(key)) return t(key); + } + + return fallback; +}; + +/** + * One row per registered type the installation has not switched off, in the + * user's language. Rows are generated from the definitions, never stored: a + * new plugin's types appear without a migration. + */ +export const getNotificationPreferences = async ( + c: NotificationsContext, + { language, userId }: { language?: string; userId: number }, +): Promise => { + const db = c.get("db"); + const [settings, [state]] = await Promise.all([ + loadNotificationSettings(db), + db + .select() + .from(core_notification_user_state) + .where(eq(core_notification_user_state.userId, userId)) + .limit(1), + ]); + const { t } = await createTranslatorCache(c)(language); + const emailConfigured = isEmailConfigured(c, settings); + const preferences = state?.preferences ?? {}; + + const types = getNotificationRegistry(c) + .list.filter(({ definition }) => { + const policy = settings.policies.get(definition.id); + + return policy?.enabled !== false || definition.mandatory; + }) + .map(({ definition, pluginId }): NotificationTypePreferenceView => { + const policy = settings.policies.get(definition.id); + const emailAvailable = + isNotificationEmailAvailable({ definition, emailConfigured, policy }) && + !definition.mandatory; + + return { + category: definition.category, + categoryLabel: translateOr( + t, + [ + `core.notifications.categories.${definition.category}`, + `${pluginId}.notifications.categories.${definition.category}`, + ], + definition.category, + ), + description: definition.description + ? translateOr(t, [definition.description], definition.description) + : null, + emailModes: emailAvailable + ? ["none", "immediate", "daily", "weekly"] + : [], + id: definition.id, + label: translateOr(t, [definition.label], definition.id), + mandatory: !!definition.mandatory, + pluginId, + value: resolveNotificationChannels({ + definition, + emailConfigured, + muted: false, + policy, + preference: preferences[definition.id], + }), + }; + }); + + return { + digestHour: state?.digestHour ?? 8, + digestWeekday: state?.digestWeekday ?? 1, + timeZone: state?.timeZone ?? null, + types, + }; +}; + +export interface UpdateNotificationPreferencesArgs { + digestHour?: number; + digestWeekday?: number; + timeZone?: null | string; + types?: Record; +} + +/** + * Saves the user's choices, refusing anything the installation does not + * offer: unknown types, email for a type without email, and changes to + * mandatory types. + */ +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, settings); + + if (args.timeZone && !isValidTimeZone(args.timeZone)) { + throw new HTTPException(400, { message: "Unknown time zone." }); + } + + 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; + if (definition.mandatory) { + throw new HTTPException(400, { + message: `"${typeId}" cannot be changed.`, + }); + } + if ( + preference.email !== undefined && + preference.email !== "none" && + !isNotificationEmailAvailable({ + definition, + emailConfigured, + policy: settings.policies.get(typeId), + }) + ) { + throw new HTTPException(400, { + message: `"${typeId}" has no email channel.`, + }); + } + patch[typeId] = { + ...(preference.email === undefined ? {} : { email: preference.email }), + ...(preference.inApp === undefined ? {} : { inApp: preference.inApp }), + }; + } + + 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(), + ...(args.timeZone === undefined ? {} : { timeZone: args.timeZone }), + ...(args.digestHour === undefined + ? {} + : { digestHour: args.digestHour }), + ...(args.digestWeekday === undefined + ? {} + : { digestWeekday: args.digestWeekday }), + }) + .where(eq(core_notification_user_state.userId, 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..06e33a510 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/publish.ts @@ -0,0 +1,242 @@ +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"; + +/** Above this, name the audience with `followersOf` and let core stream it. */ +export const MAX_EXPLICIT_RECIPIENTS = 100_000; +const MAX_FOLLOWER_SUBJECTS = 20; + +export interface PublishNotificationArgs { + /** + * Who caused it. Defaults to the signed-in admin or user of the request; + * pass `null` for system notifications. + */ + actorId?: null | number; + /** + * Deliver to the actor too. Off by default - nobody needs to be told about + * their own comment - but right for "your export is ready" style messages. + */ + allowSelf?: boolean; + data: NoInfer; + /** Everyone following any of these subjects is a candidate too. */ + followersOf?: NotificationSubject[]; + /** + * Stable for one real-world happening, e.g. `comment:42`. Publishing the + * same key twice for a type is a no-op that returns the first event. + */ + idempotencyKey: string; + /** Candidate user ids chosen by the plugin's business rules. */ + recipients?: readonly number[]; + subject?: NotificationSubject; + /** + * Join the producer's transaction: the event, and the durable task that + * delivers it, commit or roll back with the producer's own write. + */ + tx?: NotificationsDb; + type: NotificationTypeDefinition | string; +} + +export interface PublishNotificationResult { + /** `true` when the idempotency key had already been published. */ + 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 }; +}; + +export 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; +}; + +/** Validates, deduplicates and durably records one event. */ +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 followersOf = (args.followersOf ?? []).map(normalizeSubject); + if (followersOf.length > MAX_FOLLOWER_SUBJECTS) { + throw new NotificationPublishError( + `followersOf accepts at most ${MAX_FOLLOWER_SUBJECTS} subjects.`, + ); + } + + 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} explicit recipients per event; use followersOf for larger audiences.`, + ); + } + + const db = args.tx ?? c.get("db"); + const hasAudience = recipientIds.length > 0 || followersOf.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, + followersOf, + 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) { + // Only reachable when a concurrent transaction holds the key and has + // not committed yet; the caller's retry will see it. + 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..dc10b393c --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/render.ts @@ -0,0 +1,294 @@ +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; +} + +export interface ActorSummary { + actorCount: number; + actors: NotificationActor[]; +} + +const ACTOR_PREVIEW_LIMIT = 3; + +/** Loads users for actor previews in one query. */ +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) }, + ]), + ); +}; + +/** + * The newest distinct actors of each inbox item, and how many distinct actors + * it has in total - one grouped query for a whole page of items. + */ +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), + }, + ]), + ); +}; + +export const subjectOf = ( + row: Pick, +): NotificationSubject | null => + row.subjectType && row.subjectId + ? { id: row.subjectId, type: row.subjectType } + : null; + +/** Which of `userIds` the type still lets see this event. */ +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 })); +}; + +export interface ResolvedEvent { + data: unknown; + registered: RegisteredNotificationType; +} + +/** + * The registered type and parsed data of an event, or `null` when it can no + * longer be shown: its plugin is gone, or its data no longer parses. + */ +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, +}); + +/** In-app text for one item, sanitized. `null` if the type failed to render. */ +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, + }; + } +}; + +/** Translators per locale, created once per operation. */ +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/run-now.ts b/packages/vitnode/src/api/models/notifications/run-now.ts new file mode 100644 index 000000000..329dd269d --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/run-now.ts @@ -0,0 +1,10 @@ +import { processQueueTasksByIds } from "@/api/modules/queue/helpers/process-queue-tasks"; + +import type { NotificationsContext } from "./shared"; + +export const runQueuedTasksNow = async ( + c: NotificationsContext, + queueIds: number[], +): Promise => { + await processQueueTasksByIds(c, queueIds); +}; 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..06bd06fd1 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/shared.ts @@ -0,0 +1,138 @@ +import type { Context } from "hono"; + +import { inArray } from "drizzle-orm"; + +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 } from "@/api/lib/notifications/preferences"; +import { core_notification_settings } from "@/database/notifications"; +import { notificationsStateChannel } from "@/ws/notifications"; + +/** A database handle or a transaction - anything that can run a query. */ +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"; +export const typeSettingsKey = (type: string): string => `type:${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 clampInt = ( + value: unknown, + min: number, + max: number, + fallback: number, +) => + typeof value === "number" && Number.isInteger(value) + ? Math.min(max, Math.max(min, value)) + : fallback; + +export const normalizeGlobalSettings = ( + value: Record | undefined, +): NotificationGlobalSettings => { + const defaults = DEFAULT_NOTIFICATION_SETTINGS; + + return { + emailBatchSize: clampInt( + value?.emailBatchSize, + 1, + 500, + defaults.emailBatchSize, + ), + emailConcurrency: clampInt( + value?.emailConcurrency, + 1, + 20, + defaults.emailConcurrency, + ), + emailEnabled: + typeof value?.emailEnabled === "boolean" + ? value.emailEnabled + : defaults.emailEnabled, + fanoutBatchSize: clampInt( + value?.fanoutBatchSize, + 10, + 5000, + defaults.fanoutBatchSize, + ), + retentionDays: clampInt( + value?.retentionDays, + 1, + 3650, + defaults.retentionDays, + ), + }; +}; + +/** One query: the global settings and every type policy. */ +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:")) { + policies.set(row.key.slice(5), row.value); + } + } + + return { global: normalizeGlobalSettings(global), policies }; +}; + +/** Whether notification emails can be sent at all on this installation. */ +export const isEmailConfigured = ( + c: NotificationsContext, + settings: NotificationSettingsSnapshot, +): boolean => !!c.get("core").email?.adapter && settings.global.emailEnabled; + +/** + * Sends each user their committed unread state. Call only after the + * transaction that produced `states` has committed. + */ +export const sendNotificationStates = ( + c: NotificationsContext, + states: Iterable, +): void => { + const realtime = c.get("realtime"); + for (const { userId, ...message } of states) { + realtime.sendToUser(userId, notificationsStateChannel, message); + } +}; + +export const loadSettingsForKeys = async ( + db: NotificationsDb, + keys: string[], +) => + keys.length === 0 + ? [] + : await db + .select() + .from(core_notification_settings) + .where(inArray(core_notification_settings.key, keys)); diff --git a/packages/vitnode/src/api/models/notifications/subscriptions.ts b/packages/vitnode/src/api/models/notifications/subscriptions.ts new file mode 100644 index 000000000..95bf82776 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/subscriptions.ts @@ -0,0 +1,132 @@ +import { and, asc, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import type { NotificationSubject } from "@/lib/notifications/types"; + +import { core_notification_subscriptions } from "@/database/notifications"; + +import type { NotificationsContext } from "./shared"; + +import { getNotificationRegistry } from "./shared"; + +export type NotificationSubscriptionState = "following" | "muted" | "none"; + +const subjectWhere = (userId: number, subject: { id: string; type: string }) => + and( + eq(core_notification_subscriptions.userId, userId), + eq(core_notification_subscriptions.subjectType, subject.type), + eq(core_notification_subscriptions.subjectId, subject.id), + ); + +const normalize = (subject: NotificationSubject) => ({ + id: String(subject.id), + type: subject.type, +}); + +export const getSubscriptionState = async ( + c: NotificationsContext, + userId: number, + subject: NotificationSubject, +): Promise => { + const [row] = await c + .get("db") + .select({ state: core_notification_subscriptions.state }) + .from(core_notification_subscriptions) + .where(subjectWhere(userId, normalize(subject))) + .limit(1); + + return row?.state ?? "none"; +}; + +/** + * Follows, mutes or clears one subject for one user. Following and muting are + * one state, so muting a followed subject unfollows it and vice versa. Throws + * a 400 for unknown subject types and a 403 when the plugin's `canFollow` + * refuses. + */ +export const setSubscriptionState = async ( + c: NotificationsContext, + userId: number, + subject: NotificationSubject, + state: NotificationSubscriptionState, +): Promise => { + const normalized = normalize(subject); + const registered = getNotificationRegistry(c).getSubject(normalized.type); + if (!registered || normalized.id.length === 0 || normalized.id.length > 100) { + throw new HTTPException(400, { message: "Unknown notification subject." }); + } + + const db = c.get("db"); + if (state === "none") { + await db + .delete(core_notification_subscriptions) + .where(subjectWhere(userId, normalized)); + + return "none"; + } + + if (state === "following") { + const { canFollow, followable } = registered.definition; + if (followable === false) { + throw new HTTPException(400, { + message: "This subject cannot be followed.", + }); + } + if (canFollow && !(await canFollow({ c, id: normalized.id, userId }))) { + throw new HTTPException(403); + } + } + + await db + .insert(core_notification_subscriptions) + .values({ + createdAt: new Date(), + state, + subjectId: normalized.id, + subjectType: normalized.type, + userId, + }) + .onConflictDoUpdate({ + set: { createdAt: new Date(), state }, + target: [ + core_notification_subscriptions.userId, + core_notification_subscriptions.subjectType, + core_notification_subscriptions.subjectId, + ], + }); + + return state; +}; + +export const listSubscriptions = async ( + c: NotificationsContext, + { + limit, + state, + userId, + }: { + limit: number; + state?: "following" | "muted"; + userId: number; + }, +) => + await c + .get("db") + .select({ + createdAt: core_notification_subscriptions.createdAt, + state: core_notification_subscriptions.state, + subjectId: core_notification_subscriptions.subjectId, + subjectType: core_notification_subscriptions.subjectType, + }) + .from(core_notification_subscriptions) + .where( + and( + eq(core_notification_subscriptions.userId, userId), + state ? eq(core_notification_subscriptions.state, state) : undefined, + ), + ) + .orderBy( + asc(core_notification_subscriptions.subjectType), + asc(core_notification_subscriptions.subjectId), + ) + .limit(limit); 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/notifications/notifications.admin.module.ts b/packages/vitnode/src/api/modules/admin/notifications/notifications.admin.module.ts new file mode 100644 index 000000000..3f5857db3 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/notifications.admin.module.ts @@ -0,0 +1,32 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + listNotificationDeliveriesRoute, + retryNotificationDeliveryRoute, +} from "./routes/deliveries.route"; +import { + reconcileNotificationCountsRoute, + runNotificationCleanupRoute, + sendNotificationTestEmailRoute, +} from "./routes/maintenance.route"; +import { getNotificationsOverviewRoute } from "./routes/overview.route"; +import { + updateNotificationSettingsRoute, + updateNotificationTypePolicyRoute, +} from "./routes/settings.route"; + +export const notificationsAdminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "notifications", + routes: [ + getNotificationsOverviewRoute, + updateNotificationSettingsRoute, + updateNotificationTypePolicyRoute, + listNotificationDeliveriesRoute, + retryNotificationDeliveryRoute, + sendNotificationTestEmailRoute, + reconcileNotificationCountsRoute, + runNotificationCleanupRoute, + ], +}); 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..66da47a94 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/deliveries.route.ts @@ -0,0 +1,100 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { + listNotificationDeliveries, + retryNotificationDelivery, +} from "@/api/models/notifications/admin"; +import { CONFIG_PLUGIN } from "@/config"; + +const DELIVERY_STATUSES = [ + "pending", + "sending", + "sent", + "failed", + "skipped", +] as const; + +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: z.enum(DELIVERY_STATUSES).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(["immediate", "daily", "weekly", "test"]), + providerMessageId: z.string().nullable(), + sentAt: z.date().nullable(), + skipReason: z.string().nullable(), + status: z.enum(DELIVERY_STATUSES), + 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..31a8efa7b --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/maintenance.route.ts @@ -0,0 +1,103 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { + queueNotificationCleanup, + queueNotificationTestEmail, + reconcileNotifications, +} from "@/api/models/notifications/admin"; +import { CONFIG_PLUGIN } from "@/config"; + +const success = { + 200: { + content: { + "application/json": { schema: z.object({ success: z.boolean() }) }, + }, + description: "Queued", + }, + 403: { description: "Access Denied" }, +} as const; + +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"))), +}); + +export const runNotificationCleanupRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "notifications", permission: "can_manage" }, + route: { + method: "post", + description: + "Queue the retention cleanup now instead of waiting for the nightly run.", + path: "/cleanup", + responses: success, + }, + handler: async c => { + await queueNotificationCleanup(c); + + return c.json({ success: true }); + }, +}); 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..9407a3b97 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/overview.route.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { getNotificationsOverview } from "@/api/models/notifications/admin"; +import { CONFIG_PLUGIN } from "@/config"; +import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types"; + +const zodEmailMode = z.enum(NOTIFICATION_EMAIL_MODES); +const zodCounts = z.record(z.string(), z.number()); + +export const zodNotificationGlobalSettings = z.object({ + emailBatchSize: z.number().int().min(1).max(500), + emailConcurrency: z.number().int().min(1).max(20), + emailEnabled: z.boolean(), + fanoutBatchSize: z.number().int().min(10).max(5000), + retentionDays: z.number().int().min(1).max(3650), +}); + +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({ + email: z.object({ + adapterConfigured: z.boolean(), + enabled: z.boolean(), + }), + health: z.object({ + cronActive: z.boolean(), + cronStale: z.boolean(), + deliveries: zodCounts, + events: zodCounts, + oldestPendingEventAt: z.date().nullable(), + queue: zodCounts, + }), + settings: zodNotificationGlobalSettings, + types: z.array( + z.object({ + category: z.string(), + defaults: z.object({ + email: zodEmailMode, + 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(), + email: zodEmailMode.nullable(), + enabled: z.boolean(), + inApp: z.boolean().nullable(), + }), + 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..6cde443ea --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/settings.route.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { + updateNotificationGlobalSettings, + updateNotificationTypePolicy, +} from "@/api/models/notifications/admin"; +import { CONFIG_PLUGIN } from "@/config"; +import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types"; + +import { zodNotificationGlobalSettings } from "./overview.route"; + +export const updateNotificationSettingsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "notifications", permission: "can_edit" }, + route: { + method: "put", + description: + "Update installation-wide notification settings: email switch, batch sizes and retention.", + path: "/settings", + request: { + body: { + required: true, + content: { + "application/json": { + schema: zodNotificationGlobalSettings.partial(), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: zodNotificationGlobalSettings }, + }, + description: "Saved settings", + }, + 403: { description: "Access Denied" }, + }, + }, + handler: async c => + c.json(await updateNotificationGlobalSettings(c, c.req.valid("json"))), +}); + +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: on/off, whether it may email, and the defaults for users who never chose.", + 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(), + email: z.enum(NOTIFICATION_EMAIL_MODES).optional(), + enabled: z.boolean().optional(), + inApp: 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/routes/notifications.route.ts b/packages/vitnode/src/api/modules/admin/routes/notifications.route.ts index f1b32ee6b..97ce6ccfa 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,19 +50,31 @@ export const sendNotificationRoute = buildRoute({ }, }, }, - handler: c => { + handler: async c => { const admin = c.get("admin")?.user; if (!admin) throw new HTTPException(403); const { userId, title, description, type } = c.req.valid("json"); - // Delivered only to that user's connections, across all their browsers. + // The toast this route always sent, unchanged: delivered only to that + // user's open connections, across all their browsers. c.get("realtime").sendToUser(userId, notificationsChannel, { description, title, type, }); + // New: the same message is kept in their inbox, so a user who was offline + // (or closed the toast) still sees it. Each send is its own message. + 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/notifications/notifications.module.ts b/packages/vitnode/src/api/modules/notifications/notifications.module.ts new file mode 100644 index 000000000..76c0a81b1 --- /dev/null +++ b/packages/vitnode/src/api/modules/notifications/notifications.module.ts @@ -0,0 +1,53 @@ +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 { + getSubscriptionRoute, + listSubscriptionsRoute, + setSubscriptionRoute, +} from "./routes/subscriptions.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, + listSubscriptionsRoute, + getSubscriptionRoute, + setSubscriptionRoute, + ], + 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..9a4d3b612 --- /dev/null +++ b/packages/vitnode/src/api/modules/notifications/routes/mark.route.ts @@ -0,0 +1,149 @@ +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({ + /** The `activitySeq` the client rendered. */ + 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..af4aac470 --- /dev/null +++ b/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts @@ -0,0 +1,111 @@ +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"; + +const zodPreferences = z.object({ + digestHour: z.number(), + digestWeekday: z.number(), + timeZone: z.string().nullable(), + types: z.array( + z.object({ + category: z.string(), + categoryLabel: z.string(), + description: z.string().nullable(), + emailModes: z.array(zodNotificationEmailMode), + id: z.string(), + label: z.string(), + mandatory: z.boolean(), + pluginId: z.string(), + value: z.object({ email: zodNotificationEmailMode, inApp: 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: zodPreferences } }, + 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({ + digestHour: z.number().int().min(0).max(23).optional(), + digestWeekday: z.number().int().min(0).max(6).optional(), + timeZone: z.string().max(64).nullable().optional(), + types: z + .record( + z.string().max(100), + z.object({ + email: zodNotificationEmailMode.optional(), + inApp: 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/routes/subscriptions.route.ts b/packages/vitnode/src/api/modules/notifications/routes/subscriptions.route.ts new file mode 100644 index 000000000..e212f4b91 --- /dev/null +++ b/packages/vitnode/src/api/modules/notifications/routes/subscriptions.route.ts @@ -0,0 +1,169 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { getNotificationRegistry } from "@/api/models/notifications/shared"; +import { + getSubscriptionState, + listSubscriptions, + setSubscriptionState, +} from "@/api/models/notifications/subscriptions"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + requireNotificationUser, + unauthorizedResponse, + zodSubscriptionState, +} from "../schema"; + +const zodSubject = z.object({ + subjectId: z.string().min(1).max(100), + subjectType: z.string().min(1).max(100), +}); + +export const getSubscriptionRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: + "Whether the signed-in user follows or muted one subject, e.g. a blog category.", + path: "/subscriptions/{subjectType}/{subjectId}", + request: { params: zodSubject }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ state: zodSubscriptionState }), + }, + }, + description: "Subscription state", + }, + ...unauthorizedResponse, + }, + }, + handler: async c => { + const user = requireNotificationUser(c); + const { subjectId, subjectType } = c.req.valid("param"); + + return c.json({ + state: await getSubscriptionState(c, user.id, { + id: subjectId, + type: subjectType, + }), + }); + }, +}); + +export const setSubscriptionRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "put", + description: + "Follow, mute or clear one subject for the signed-in user. Following asks the owning plugin's canFollow first.", + path: "/subscriptions", + request: { + body: { + required: true, + content: { + "application/json": { + schema: zodSubject.extend({ state: zodSubscriptionState }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ state: zodSubscriptionState }), + }, + }, + description: "Saved", + }, + 400: { description: "Unknown subject type" }, + 403: { description: "The plugin does not let this user follow it" }, + ...unauthorizedResponse, + }, + }, + handler: async c => { + const user = requireNotificationUser(c); + const { state, subjectId, subjectType } = c.req.valid("json"); + + return c.json({ + state: await setSubscriptionState( + c, + user.id, + { id: subjectId, type: subjectType }, + state, + ), + }); + }, +}); + +export const listSubscriptionsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: "The subjects the signed-in user follows or muted.", + path: "/subscriptions", + request: { + query: z.object({ state: z.enum(["following", "muted"]).optional() }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + items: z.array( + z.object({ + createdAt: z.date(), + /** A readable name, when the owning plugin provides one. */ + label: z.string().nullable(), + state: z.enum(["following", "muted"]), + subjectId: z.string(), + subjectType: z.string(), + }), + ), + }), + }, + }, + description: "Subscriptions", + }, + ...unauthorizedResponse, + }, + }, + handler: async c => { + const user = requireNotificationUser(c); + const { state } = c.req.valid("query"); + const items = await listSubscriptions(c, { + limit: 200, + state, + userId: user.id, + }); + + const registry = getNotificationRegistry(c); + const locale = c.get("i18n").resolveSupportedLocale(user.language); + const labels = new Map(); + for (const type of new Set(items.map(item => item.subjectType))) { + const resolve = registry.getSubject(type)?.definition.resolveLabels; + if (!resolve) continue; + try { + const ids = items + .filter(item => item.subjectType === type) + .map(item => item.subjectId); + const resolved = await resolve({ c, ids, locale }); + for (const [id, label] of Object.entries(resolved)) { + labels.set(`${type}:${id}`, label.slice(0, 200)); + } + } catch { + // A plugin failing to name its subjects only costs the pretty label. + } + } + + return c.json({ + items: items.map(item => ({ + ...item, + label: labels.get(`${item.subjectType}:${item.subjectId}`) ?? null, + })), + }); + }, +}); 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..067efb2ac --- /dev/null +++ b/packages/vitnode/src/api/modules/notifications/schema.ts @@ -0,0 +1,70 @@ +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(), +}); + +export const zodNotificationSubject = z.object({ + id: z.string(), + type: z.string(), +}); + +export 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 zodSubscriptionState = z.enum(["following", "muted", "none"]); + +export const zodNotificationIdParams = z.object({ + id: z.coerce.number().int().positive(), +}); + +export const unauthorizedResponse = { + 401: { description: "Not signed in" }, +} as const; + +/** The signed-in user - every inbox route acts on them and nobody else. */ +export const requireNotificationUser = (c: Context) => { + const user = c.get("user"); + if (!user) throw new HTTPException(401, { message: "Unauthorized" }); + + return user; +}; + +export const jsonContent = (schema: T) => ({ + content: { "application/json": { schema } }, +}); 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..916095c5c --- /dev/null +++ b/packages/vitnode/src/api/modules/notifications/tasks/notification-tasks.ts @@ -0,0 +1,80 @@ +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); + + // Out of time with recipients left: progress is saved on the event, so a + // fresh task picks up at the next batch. + 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: "17 3 * * *", + 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..991ed183c 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,166 @@ const QUEUE_BATCH_SIZE = 25; const QUEUE_LOCK_KEY = "queue:process"; const QUEUE_LOCK_TTL_SECONDS = 55; const QUEUE_RETENTION_DAYS = 7; +/** + * A task still `processing` after this long belonged to a worker that died + * mid-run. Handlers must stay well under it - long work saves progress and + * re-queues itself instead. + */ +export const QUEUE_STALE_PROCESSING_MINUTES = 15; + +type QueueRow = typeof core_queue.$inferSelect; + +/** Claims due tasks matching `where`, skipping rows another worker holds. */ +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)); + } +}; + +/** + * Puts tasks whose worker died mid-run back in line - or fails them once + * they have used every attempt - so a crash never strands a task in + * `processing` forever. + */ +export const reclaimStaleQueueTasks = async ( + c: Context, + now: Date = new Date(), +): Promise => { + const stale = lt( + core_queue.reservedAt, + new Date(now.getTime() - QUEUE_STALE_PROCESSING_MINUTES * 60_000), + ); + const message = "The worker stopped before the task finished."; + const db = c.get("db"); + + const failed = await db + .update(core_queue) + .set({ + completedAt: now, + lastError: message, + reservedAt: null, + status: "failed", + }) + .where( + and( + eq(core_queue.status, "processing"), + stale, + sql`${core_queue.attempts} >= ${core_queue.maxAttempts}`, + ), + ) + .returning({ id: core_queue.id }); + + const retried = await db + .update(core_queue) + .set({ + availableAt: now, + lastError: message, + reservedAt: null, + status: "pending", + }) + .where(and(eq(core_queue.status, "processing"), stale)) + .returning({ id: core_queue.id }); + + return failed.length + retried.length; +}; + +/** + * Runs specific tasks right away, outside the worker's tick - used to start a + * request's own work once its response is sent. Uses the same claim, so a task + * the worker already took (or that never committed) is simply skipped. + */ +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 +185,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/session.route.ts b/packages/vitnode/src/api/modules/users/routes/session.route.ts index 4ea5ab18b..901b5ec4b 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,7 @@ import { z } from "zod"; import { isStaff } from "@/api/lib/check-staff-permission"; import { buildRoute } from "@/api/lib/route"; +import { getNotificationState } from "@/api/models/notifications/inbox"; import { CONFIG_PLUGIN } from "@/config"; export const sessionRoute = buildRoute({ @@ -36,6 +37,15 @@ export const sessionRoute = buildRoute({ birthday: z.date().nullable(), isAdmin: z.boolean(), isModerator: z.boolean(), + /** + * The canonical unread count and its revision. A client keeps + * whichever state - this or a WebSocket update - has the + * higher revision. + */ + notifications: z.object({ + revision: z.number(), + unread: z.number(), + }), }) .nullable(), }), @@ -49,11 +59,14 @@ export const sessionRoute = buildRoute({ const user = c.get("user"); if (!user) return c.json({ user: null }); - const [isAdmin, isModerator] = await Promise.all([ + const [isAdmin, isModerator, notifications] = await Promise.all([ isStaff(c, { type: "admin", userId: user.id }), isStaff(c, { type: "moderator", userId: user.id }), + // One primary-key read - never cached with the session user, so a + // count changed by another tab or device is never served stale. + 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/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/database/notifications.ts b/packages/vitnode/src/database/notifications.ts new file mode 100644 index 000000000..57d954959 --- /dev/null +++ b/packages/vitnode/src/database/notifications.ts @@ -0,0 +1,273 @@ +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"; + +/** + * One published notification event, stored once however many people receive + * it. `recipientIds` and `followersOf` are the candidates the publisher named; + * the fan-out cursor records how far delivery got, so a retry resumes there. + */ +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[]`), + followersOf: t + .jsonb() + .$type<{ id: string; type: string }[]>() + .notNull() + .default([]), + allowSelf: t.boolean().notNull().default(false), + status: t + .varchar({ + enum: ["pending", "processing", "completed", "failed"], + length: 20, + }) + .notNull() + .default("pending"), + /** Index into `recipientIds` processed so far. */ + recipientCursor: t.integer().notNull().default(0), + /** Index into `followersOf` currently being streamed. */ + followerSubjectCursor: t.integer().notNull().default(0), + /** Last follower user id processed within that subject. */ + followerUserCursor: 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), + ], +); + +/** + * A recipient's inbox entry. Ungrouped events get one row each; grouped events + * share one row per recipient, type, group key and time bucket. Unread means + * `readSeq < activitySeq` on a row that is not archived. + */ +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 }), + // Restrict, not cascade: deleting an event must never drop unread items + // behind the counter's back. Cleanup removes items first, then events. + 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), + ], +); + +/** + * One row per event and recipient: the deduplication key for fan-out, the + * membership of an event in a grouped inbox item, and the email state of that + * event for that person. + */ +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(), + /** `null` when the user takes this type by email only. */ + notificationId: t + .bigint({ mode: "number" }) + .references(() => core_notifications.id, { onDelete: "cascade" }), + /** `activitySeq` of the inbox item right after this event joined it. */ + seq: t.integer(), + /** Waiting for an email (immediate or digest) that has not claimed it. */ + 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; +} + +/** + * Everything notifications keep per user in one row: the canonical unread + * count and its revision, digest schedule, and per-type preferences. Every + * inbox write locks this row first, which serializes a user's inbox changes. + */ +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), + timeZone: t.varchar({ length: 64 }), + digestHour: t.smallint().notNull().default(8), + /** 0 = Sunday ... 6 = Saturday. */ + digestWeekday: t.smallint().notNull().default(1), + preferences: t + .jsonb() + .$type>() + .notNull() + .default({}), + updatedAt: t.timestamp().notNull().defaultNow(), + }), + t => [index("core_notification_user_state_unread_idx").on(t.unreadCount)], +); + +/** A user following or muting one subject, such as a blog category. */ +export const core_notification_subscriptions = camelCase.table.withRLS( + "core_notification_subscriptions", + t => ({ + userId: t + .integer() + .references(() => core_users.id, { onDelete: "cascade" }) + .notNull(), + subjectType: t.varchar({ length: 100 }).notNull(), + subjectId: t.varchar({ length: 100 }).notNull(), + state: t.varchar({ enum: ["following", "muted"], length: 20 }).notNull(), + createdAt: t.timestamp().notNull().defaultNow(), + }), + t => [ + primaryKey({ columns: [t.userId, t.subjectType, t.subjectId] }), + index("core_notification_subscriptions_subject_idx").on( + t.subjectType, + t.subjectId, + t.state, + t.userId, + ), + ], +); + +/** One email send - immediate, digest or test - and its attempts. */ +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), + ], +); + +/** Installation policy: `global`, and one `type:{typeId}` per type. */ +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/emails/notification.tsx b/packages/vitnode/src/emails/notification.tsx new file mode 100644 index 000000000..10543456f --- /dev/null +++ b/packages/vitnode/src/emails/notification.tsx @@ -0,0 +1,92 @@ +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; + /** Absolute URL, already restricted to this site. */ + url: null | string; +} + +export interface NotificationEmailProps extends DefaultTemplateEmailProps { + actionLabel: string; + entries: NotificationEmailEntry[]; + heading: string; + intro?: string; + preferencesLabel: string; + preferencesUrl: string; +} + +/** + * One template for an immediate email (one entry) and a digest (many). All + * text arrives as plain strings, so React escapes it - nothing a plugin or an + * actor wrote can inject markup. + */ +export default function NotificationEmailTemplate({ + actionLabel, + entries, + heading, + intro, + preferencesLabel, + preferencesUrl, + ...props +}: NotificationEmailProps) { + return ( + +

+ + {heading} + + {intro ? {intro} : null} + + {entries.map((entry, index) => ( + // A rendered email is never re-ordered, and titles may repeat. + // 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/notifications/types.ts b/packages/vitnode/src/lib/notifications/types.ts new file mode 100644 index 000000000..23ab299ca --- /dev/null +++ b/packages/vitnode/src/lib/notifications/types.ts @@ -0,0 +1,48 @@ +export const NOTIFICATION_EMAIL_MODES = [ + "none", + "immediate", + "daily", + "weekly", +] as const; + +export type NotificationEmailMode = (typeof NOTIFICATION_EMAIL_MODES)[number]; + +export const NOTIFICATION_CATEGORIES = [ + "account", + "content", + "social", + "system", +] as const; + +/** Why a user's unread state changed - sent with every realtime update. */ +export type NotificationStateReason = + | "archived" + | "created" + | "read" + | "read_all" + | "reconciled" + | "removed" + | "unread"; + +/** The canonical unread state of one user, as the server last committed it. */ +export interface NotificationState { + /** + * Increases with every committed inbox change for the user. A client keeps + * whichever state has the highest revision and drops anything older. + */ + revision: number; + unread: number; +} + +export interface NotificationStateMessage extends NotificationState { + notificationId?: number; + reason: NotificationStateReason; +} + +export interface NotificationSubject { + id: number | string; + type: string; +} + +/** Day-of-week numbers as `Date#getUTCDay` returns them. */ +export const NOTIFICATION_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6] as const; 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 e7e80768e..5141554f0 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": "Retry deliveries, send test emails and run maintenance", "@vitnode/core:cron": "Cron Jobs", "@vitnode/core:cron:can_view": "View cron jobs", "@vitnode/core:cron:can_run": "Run cron jobs", @@ -485,7 +489,41 @@ }, "showing_range": "Showing {from}–{to} of {total}", "rows_per_page": "Rows per page", - "page_of": "Page {page} of {total}" + "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.", + "follow": { + "follow": "Follow", + "following": "Following", + "mute": "Mute notifications", + "unmute": "Unmute notifications", + "followed": "Following", + "muted": "Muted", + "cleared": "Notifications back to normal", + "following_desc": "You'll be notified about new activity here.", + "muted_desc": "You won't get notifications about this anymore.", + "none_desc": "Your usual notification settings apply again." + } + } }, "navigation": { "discover": { @@ -766,7 +804,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.", @@ -1031,6 +1070,45 @@ "saved_from": "{field} now follows {provider}", "saved_manual": "{field} is now manual" } + }, + "notifications": { + "title": "Notifications", + "desc": "Choose what reaches you, and whether it arrives in your inbox, by email right away, or in a daily or weekly summary.", + "in_app": "In-app", + "email": "Email", + "email_unavailable": "No email", + "mandatory": "Required", + "saved": "Preferences saved", + "saved_desc": "New notifications will follow your updated settings.", + "email_modes": { + "none": "No email", + "immediate": "Email right away", + "daily": "Daily summary", + "weekly": "Weekly summary" + }, + "schedule": { + "title": "Summary schedule", + "desc": "When daily and weekly summaries are sent, in your time zone.", + "time_zone": "Time zone", + "time_zone_desc": "Summaries follow this clock, daylight saving included.", + "hour": "Send at", + "hour_desc": "Daily summaries go out at this hour.", + "weekday": "Weekly summary day", + "weekday_desc": "Weekly summaries go out on this day, at the same hour.", + "submit": "Save schedule" + }, + "subscriptions": { + "title": "Followed and muted", + "desc": "Things you follow add to what you hear about; muted ones stay quiet.", + "empty": "You aren't following or muting anything yet.", + "following": "Following", + "muted": "Muted", + "unfollow": "Unfollow", + "unmute": "Unmute", + "unfollowed": "Unfollowed", + "unmuted": "Unmuted", + "error": "We couldn't update that. Please try again." + } } } }, @@ -1441,6 +1519,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": { @@ -1557,7 +1653,8 @@ "title": "System", "integrations": "Integrations", "navigation": "Navigation", - "files": "Files" + "files": "Files", + "notifications": "Notifications" }, "advanced": { "title": "Advanced", @@ -1898,6 +1995,193 @@ } }, "system": { + "notifications": { + "title": "Notifications", + "desc": "Keep an eye on delivery health, decide which notification types run here, and tidy up when something drifts.", + "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." + }, + "status": { + "pending": "Pending", + "processing": "Processing", + "completed": "Completed", + "sending": "Sending", + "sent": "Sent", + "failed": "Failed", + "skipped": "Skipped" + }, + "health": { + "title": "Health", + "email": { + "title": "Notification email", + "ready": "Ready", + "disabled": "Turned off", + "missing": "No adapter", + "ready_desc": "An email adapter is configured and notification emails are on.", + "disabled_desc": "An email adapter is configured, but notification emails are turned off below.", + "missing_desc": "No email adapter is configured, so notifications stay in-app only." + }, + "worker": { + "title": "Background worker", + "active": "Running", + "stale": "Stale", + "inactive": "Not configured", + "active_desc": "Cron is ticking, so fan-out and email batches keep moving.", + "stale_desc": "Cron hasn't run recently - queued work is waiting.", + "inactive_desc": "No cron adapter is configured, so queued work never runs on its own." + }, + "events": { + "title": "Events", + "desc": "Notifications waiting to reach their recipients." + }, + "deliveries": { + "title": "Email deliveries", + "desc": "Every email the notification system has tried to send." + }, + "oldest": { + "title": "Oldest pending event", + "none": "Nothing waiting", + "none_desc": "The fan-out queue is empty. Lovely.", + "desc": "The longer this gets, the later people hear about things." + }, + "failed_queue": "{count, plural, one {# notification queue task failed} other {# notification queue tasks failed}}", + "warnings": { + "title": "Heads up", + "cron_inactive": "No cron adapter is configured. Notifications are created, but fan-out and emails won't run until it is.", + "cron_stale": "Cron hasn't run recently. Check that your scheduler is still calling the cron endpoint.", + "email_adapter_missing": "No email adapter is configured, so nobody gets notification emails. In-app notifications still work.", + "failed_deliveries": "{count, plural, one {# email delivery failed} other {# email deliveries failed}}. Filter the deliveries below by \"Failed\" to inspect and retry.", + "failed_events": "{count, plural, one {# event failed} other {# events failed}} to fan out. Check the queue tasks for details." + } + }, + "settings": { + "title": "Installation settings", + "desc": "Apply to everyone on this installation.", + "read_only": "You can see these settings, but changing them needs the \"Can edit\" notifications permission.", + "enabled": "On", + "disabled": "Off", + "submit": "Save settings", + "success": "Settings saved", + "success_desc": "The new notification settings apply from the next batch.", + "emailEnabled": { + "label": "Send notification emails", + "desc": "Turn this off to keep every notification in-app, whatever people picked." + }, + "retentionDays": { + "label": "Keep notifications for", + "desc": "Older notifications are removed by the nightly cleanup. Between 1 and 3650 days.", + "unit": "days" + }, + "fanoutBatchSize": { + "label": "Fan-out batch size", + "desc": "Recipients handled per fan-out step. Between 10 and 5000." + }, + "emailBatchSize": { + "label": "Email batch size", + "desc": "Deliveries picked up per email run. Between 1 and 500." + }, + "emailConcurrency": { + "label": "Email concurrency", + "desc": "Emails sent in parallel during a run. Between 1 and 20." + } + }, + "types": { + "title": "Notification types", + "desc": "Every type registered by core and your plugins. Defaults apply to people who never changed their preferences.", + "empty": { + "title": "No notification types yet", + "desc": "Core and plugins register their types here. Nothing has signed up so far." + }, + "in_app": "In-app", + "email": "Email", + "email_unavailable": "Email unavailable", + "grouped": "Grouped", + "mandatory": "Mandatory", + "version": "v{version}", + "enabled": "Enabled", + "enabled_mandatory": "Mandatory types are always on.", + "allow_email": "Allow email", + "default_in_app": "In-app by default", + "default_email": "Default email", + "email_modes": { + "none": "None", + "immediate": "Immediately", + "daily": "Daily digest", + "weekly": "Weekly digest" + }, + "read_only": "Changing type policies needs the \"Can edit\" notifications permission.", + "success": "Type updated", + "success_desc": "\"{type}\" now follows the new policy." + }, + "deliveries": { + "title": "Email deliveries", + "desc": "Newest first. Recipients show as user ids - addresses and content never leave the server.", + "filter": "Status", + "all": "All", + "caption": "Email delivery records", + "id": "ID", + "type": "Type", + "mode": "Mode", + "state": "Status", + "attempts": "Attempts", + "error": "Error", + "user": "User", + "updated": "Updated", + "actions": "Actions", + "user_id": "#{id}", + "test_type": "Test email", + "items": "{count, plural, one {# item} other {# items}}", + "modes": { + "immediate": "Immediate", + "daily": "Daily", + "weekly": "Weekly", + "test": "Test" + }, + "load_more": "Load more", + "empty": { + "title": "No deliveries here", + "desc": "Nothing matches this filter. Quiet inboxes are happy inboxes." + }, + "retry": { + "label": "Retry delivery #{id}", + "success": "Delivery queued again", + "success_desc": "Delivery #{id} goes out with the next email run, using the same idempotency key.", + "conflict": "Only failed deliveries can be retried. This one has already moved on." + } + }, + "maintenance": { + "title": "Maintenance", + "desc": "Handy one-off jobs for when something looks off.", + "read_only": "Maintenance tools need the \"Can manage\" notifications permission.", + "retention": "Notifications older than {days, plural, one {# day} other {# days}} are cleaned up every night. Running it now just skips the wait.", + "test_email": { + "label": "Send test email to me", + "desc": "Queues a sample notification email to your own address - and only yours.", + "success": "Test email queued", + "success_desc": "Delivery #{id} is on its way to your own address. Watch it in the deliveries list below.", + "not_configured": "Notification email isn't configured yet, so there's nothing to test." + }, + "reconcile": { + "title": "Unread counts", + "desc": "Compares each person's stored unread badge with their actual inbox.", + "check": "Check unread counts", + "fix": "Fix unread counts", + "result_ok": "All unread counts match. Nothing to fix.", + "result_mismatched": "{count, plural, one {# person has} other {# people have}} a stored unread count that doesn't match their inbox.", + "confirm_title": "Fix unread counts?", + "confirm_desc": "Every mismatched unread count is recalculated from the inbox. People's badges update right away.", + "confirm_submit": "Fix counts", + "fixed": "Unread counts fixed", + "fixed_desc": "{count, plural, =0 {Everything already matched.} one {# person's count was corrected.} other {# people's counts were corrected.}}" + }, + "cleanup": { + "label": "Run cleanup now", + "success": "Cleanup queued", + "success_desc": "Old notifications are removed by the next queue run." + } + } + }, "integrations": { "title": "Integrations", "desc": "Status of the core services that power this instance.", 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..27f9e683b --- /dev/null +++ b/packages/vitnode/src/pages/admin/system/notifications.tsx @@ -0,0 +1,30 @@ +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) => ( + +); + +/** + * The heading's strings come from the loader, so the `

` and the `` + * are the same string by construction. + */ +export const route = defineAdminRoute<AdminNotificationsRouteData>({ + // `head` after `load`, always. + 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..cdeea9ae1 --- /dev/null +++ b/packages/vitnode/src/pages/notifications.tsx @@ -0,0 +1,35 @@ +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<NotificationsRouteData, NotificationsRouteSearch>) => ( + <NotificationsRouteContent + {...loaderData} + navigate={navigate} + search={search} + /> +); + +export const route = defineAuthenticatedRoute< + NotificationsRouteData, + NotificationsRouteSearch +>({ + // `head` after `load`, always. + 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..90d84f5d5 --- /dev/null +++ b/packages/vitnode/src/pages/settings/notifications.tsx @@ -0,0 +1,45 @@ +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, + notificationSubscriptionsQueryOptions, +} from "@/views/notifications/notifications-query"; + +interface NotificationSettingsData { + userId: number; +} + +const NotificationSettingsPage = ({ + loaderData, +}: PluginRoutePageProps<NotificationSettingsData>) => ( + <NotificationSettingsPanel userId={loaderData.userId} /> +); + +export const route = defineAuthenticatedRoute<NotificationSettingsData>({ + load: async ({ context }) => { + const userId = context.auth.user.id; + + await Promise.all([ + context.queryClient.query({ + ...notificationPreferencesQueryOptions({ userId }), + staleTime: "static", + }), + context.queryClient.query({ + ...notificationSubscriptionsQueryOptions({ 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: () => <FeedPendingSkeleton rows={4} />, @@ -153,6 +161,14 @@ export const routes = defineRoutes([ ], }), + page("/notifications", { + component: lazy(() => import("./pages/notifications")), + messages: NOTIFICATIONS_NAMESPACES, + pendingComponent: () => <FeedPendingSkeleton rows={6} />, + 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/notifications/index.ts b/packages/vitnode/src/tanstack/admin/notifications/index.ts new file mode 100644 index 000000000..a1af8a402 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/notifications/index.ts @@ -0,0 +1,26 @@ +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 { + AdminNotificationDeliveriesPage, + AdminNotificationDelivery, + AdminNotificationsOverview, + AdminNotificationType, +} from "@/views/admin/views/core/system/notifications/notifications-query"; +export { + notificationDeliveriesQueryOptions, + notificationsAdminQueryRoot, + notificationsOverviewQueryOptions, +} 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..2391c6e43 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/notifications/query.ts @@ -0,0 +1,51 @@ +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"; + +/** Refreshes the overview and every cached page of deliveries. */ +export const invalidateNotificationsAdmin = async ( + queryClient: QueryClient, +): Promise<void> => + await queryClient.invalidateQueries({ + queryKey: notificationsAdminQueryRoot, + }); + +/** + * The screen's writes, each followed by a refresh of what it changed. A failed + * write refreshes nothing - the cache still describes the server. + */ +export const useNotificationsAdminActions = (): NotificationsAdminActions => { + const queryClient = useQueryClient(); + + return React.useMemo<NotificationsAdminActions>(() => { + const refreshing = + <Args extends unknown[], Result extends { error?: string }>( + action: (...args: Args) => Promise<Result>, + ) => + async (...args: Args): Promise<Result> => { + const result = await action(...args); + if (result.error === undefined) { + await invalidateNotificationsAdmin(queryClient); + } + + return result; + }; + + const browser = notificationsAdminActionsInBrowser; + + return { + cleanup: refreshing(browser.cleanup), + reconcile: refreshing(browser.reconcile), + retryDelivery: refreshing(browser.retryDelivery), + sendTestEmail: refreshing(browser.sendTestEmail), + updateSettings: refreshing(browser.updateSettings), + updateTypePolicy: refreshing(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..1fff96442 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/notifications/route.tsx @@ -0,0 +1,60 @@ +import type { PluginRouteTranslator } from "@/routing"; + +import { + notificationDeliveriesQueryOptions, + notificationsOverviewQueryOptions, +} from "@/views/admin/views/core/system/notifications/notifications-query"; + +import type { AdminScreenContext } from "../screen"; + +import { requireAdminPermission } from "../screen"; + +/** + * `/admin/core/system/notifications`, as everything a TanStack Start route + * needs and nothing a route owns. + */ + +export const ADMIN_NOTIFICATIONS_NAMESPACES = [ + "admin.system.notifications", + "core.global", +] as const; + +/** What {@link loadAdminNotificationsRoute} returns - and what `head` receives. */ +export interface AdminNotificationsRouteData { + description: string; + title: string; +} + +/** The core plugin's permission module every tuple on this screen uses. */ +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<AdminNotificationsRouteData> => { + requireAdminPermission(adminAccess, NOTIFICATIONS_VIEW_PERMISSION); + + await Promise.all([ + queryClient.query({ + ...notificationsOverviewQueryOptions(), + staleTime: "static", + }), + queryClient.infiniteQuery({ + ...notificationDeliveriesQueryOptions({}), + 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..4181751df --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/notifications/screen.tsx @@ -0,0 +1,45 @@ +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 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 ( + <RouteMessages namespaces={ADMIN_NOTIFICATIONS_NAMESPACES}> + <div className="p-6"> + <PageTitle desc={description} h1={title} /> + + <NotificationsContent + actions={actions} + data={data} + permissions={{ canEdit, canManage }} + /> + </div> + </RouteMessages> + ); +}; 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/auth/queries.ts b/packages/vitnode/src/tanstack/auth/queries.ts index c65fdc7bb..f1c9a9e4d 100644 --- a/packages/vitnode/src/tanstack/auth/queries.ts +++ b/packages/vitnode/src/tanstack/auth/queries.ts @@ -4,10 +4,12 @@ import { DEVICES_IDENTITY_ROOT } from "@/views/auth/settings/devices/devices-que import { PASSKEYS_IDENTITY_ROOT } from "@/views/auth/settings/passkeys/passkeys-query"; import { SSO_CONNECTIONS_IDENTITY_ROOT } from "@/views/auth/settings/sso/sso-connections-query"; import { MY_FILES_IDENTITY_ROOT } from "@/views/files/my-files-query"; +import { NOTIFICATIONS_IDENTITY_ROOT } from "@/views/notifications/notifications-query"; export const removeUserIdentityQueries = (queryClient: QueryClient): void => { 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..97f644c9b 100644 --- a/packages/vitnode/src/tanstack/auth/state.test.ts +++ b/packages/vitnode/src/tanstack/auth/state.test.ts @@ -32,6 +32,7 @@ const userFixture = (overrides: Partial<AuthUser> = {}): AuthUser => ({ name: "Test", nameCode: "test", newsletter: false, + notifications: { revision: 0, unread: 0 }, roleId: 1, showRealName: false, ...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={<NotificationsBell />} user={user} /> <MobileNavBar items={bottomBar} menuNavigation={menuNavigation} /> diff --git a/packages/vitnode/src/tanstack/notifications/actions.ts b/packages/vitnode/src/tanstack/notifications/actions.ts new file mode 100644 index 000000000..c8c89826a --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/actions.ts @@ -0,0 +1,123 @@ +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"; + +/** + * Inbox actions for one user. Each applies the server's answer - an absolute + * count and revision - to the shared store, and on any failure re-reads the + * authoritative state, so a lost request never leaves the bell wrong. + */ +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") }); + try { + apply(await fetchNotificationState()); + } catch { + // The next realtime message or session read corrects it. + } + await invalidateNotificationLists(queryClient, userId); + }; + + const run = async ( + action: () => Promise<NotificationState>, + optimistic: () => void, + ): Promise<boolean> => { + 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") }); + }, + /** + * The only way a click marks something read. `throughSeq` is the + * activity the user saw, so a grouped event that arrived meanwhile keeps + * the item unread. + */ + 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..569b9ae08 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/bell.test.tsx @@ -0,0 +1,137 @@ +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>, +): 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 } }, + }); + // What the dropdown shows once it opens. + 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: () => <NotificationsBell /> }), + }); + + render( + <IntlProvider locale="en" messages={{}} timeZone="UTC"> + <QueryClientProvider client={queryClient}> + <RouterProvider router={router} /> + </QueryClientProvider> + </IntlProvider>, + ); + + 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 unknown 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 }); + + // The answer's absolute count replaces the badge. + 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..571de7c3b --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/bell.tsx @@ -0,0 +1,62 @@ +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); + // Fetched only while open: the bell's number never needs the list. + const recent = useQuery({ + ...recentNotificationsQueryOptions({ userId }), + enabled: open, + }); + + return ( + <NotificationsBellContent + onMarkAllRead={() => 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} + /> + ); +}; + +/** The header bell for the signed-in user; nothing for guests. */ +export const NotificationsBell = () => { + const { data: session } = useSessionQuery(); + const user = session?.user; + if (!user) return null; + + return ( + <SignedInBell + key={user.id} + sessionState={user.notifications} + userId={user.id} + /> + ); +}; diff --git a/packages/vitnode/src/tanstack/notifications/breadcrumb.tsx b/packages/vitnode/src/tanstack/notifications/breadcrumb.tsx new file mode 100644 index 000000000..b2ee5ef51 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/breadcrumb.tsx @@ -0,0 +1,8 @@ +import { useTranslations } from "use-intl"; + +/** The page's crumb. Its strings come from the route's own namespaces. */ +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..09a67f23a --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/cache.ts @@ -0,0 +1,63 @@ +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> | 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) }; +}; + +/** Patches one item in every cached list - the bell's and the page's. */ +export const patchCachedNotification = ( + queryClient: QueryClient, + userId: number, + id: number, + patch: null | Partial<NotificationItemView>, +): void => { + queryClient.setQueriesData<ListData>( + { 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<ListData>( + { queryKey: notificationsListRoot(userId) }, + data => + mapPages(data, items => items.map(item => ({ ...item, unread: false }))), + ); +}; + +export const invalidateNotificationLists = async ( + queryClient: QueryClient, + userId: number, +): Promise<void> => { + await queryClient.invalidateQueries({ + queryKey: notificationsListRoot(userId), + }); +}; diff --git a/packages/vitnode/src/tanstack/notifications/follow-subject-button.tsx b/packages/vitnode/src/tanstack/notifications/follow-subject-button.tsx new file mode 100644 index 000000000..dec81242f --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/follow-subject-button.tsx @@ -0,0 +1,124 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { BellOffIcon, BellPlusIcon, BellRingIcon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { NotificationSubject } from "@/lib/notifications/types"; +import type { NotificationSubscriptionState } from "@/views/notifications/notifications-actions"; + +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { setNotificationSubscriptionInBrowser } from "@/views/notifications/notifications-actions"; +import { + notificationSubscriptionQueryOptions, + notificationSubscriptionsQueryKey, +} from "@/views/notifications/notifications-query"; + +import { useSessionQuery } from "../auth/session-query"; + +/** + * Follow and mute controls for one subject, for any plugin page: + * + * ```tsx + * <FollowSubjectButton subject={{ type: "blog.category", id: category.id }} /> + * ``` + * + * Following adds the user to audiences the plugin publishes with + * `followersOf`; muting silences everything about the subject. The subject + * type must be registered with `buildNotificationSubject`. Guests see nothing. + */ +export const FollowSubjectButton = ({ + canMute = true, + subject, +}: { + /** Hide the mute toggle, e.g. where a subject is only ever followed. */ + canMute?: boolean; + subject: NotificationSubject; +}) => { + const { data: session } = useSessionQuery(); + const userId = session?.user?.id; + if (userId === undefined) return null; + + return ( + <SignedInFollowButton canMute={canMute} subject={subject} userId={userId} /> + ); +}; + +const SignedInFollowButton = ({ + canMute, + subject, + userId, +}: { + canMute: boolean; + subject: NotificationSubject; + userId: number; +}) => { + const t = useTranslations("core.global.notifications.follow"); + const tErrors = useTranslations("core.global.errors"); + const queryClient = useQueryClient(); + const options = notificationSubscriptionQueryOptions({ subject, userId }); + const { data: state, isPending } = useQuery(options); + const [isSaving, setIsSaving] = React.useState(false); + + const set = async (next: NotificationSubscriptionState) => { + setIsSaving(true); + try { + const saved = await setNotificationSubscriptionInBrowser({ + state: next, + subject, + }); + queryClient.setQueryData(options.queryKey, saved); + toast.success( + next === "following" + ? t("followed") + : next === "muted" + ? t("muted") + : t("cleared"), + { description: t(`${next}_desc`) }, + ); + } catch { + toast.error(tErrors("title"), { + description: tErrors("internal_server_error"), + }); + } finally { + setIsSaving(false); + await queryClient.invalidateQueries({ + queryKey: notificationSubscriptionsQueryKey(userId), + refetchType: "none", + }); + } + }; + + const following = state === "following"; + const muted = state === "muted"; + const disabled = isPending || isSaving; + + return ( + <ButtonGroup> + <Button + aria-pressed={following} + disabled={disabled} + onClick={() => void set(following ? "none" : "following")} + size="sm" + variant={following ? "secondary" : "outline"} + > + {following ? <BellRingIcon /> : <BellPlusIcon />} + {following ? t("following") : t("follow")} + </Button> + {canMute ? ( + <Button + aria-label={muted ? t("unmute") : t("mute")} + aria-pressed={muted} + disabled={disabled} + onClick={() => void set(muted ? "none" : "muted")} + size="icon-sm" + title={muted ? t("unmute") : t("mute")} + variant={muted ? "secondary" : "outline"} + > + <BellOffIcon /> + </Button> + ) : null} + </ButtonGroup> + ); +}; diff --git a/packages/vitnode/src/tanstack/notifications/index.ts b/packages/vitnode/src/tanstack/notifications/index.ts new file mode 100644 index 000000000..7984ac062 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/index.ts @@ -0,0 +1,7 @@ +export { useNotificationActions } from "./actions"; +export { NotificationsBell } from "./bell"; +export { FollowSubjectButton } from "./follow-subject-button"; +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..f4c7fcba6 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/namespaces.ts @@ -0,0 +1,8 @@ +/** + * Message namespaces of the Notifications Center page. Their own module + * because `routes.tsx` is read by the build in Node. + */ +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..0bd7bc521 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/route-search.ts @@ -0,0 +1,16 @@ +export interface NotificationsRouteSearch { + category?: string; + filter?: "unread"; +} + +const CATEGORY = /^[a-z0-9_-]{1,50}$/; + +/** Only the two filters the page understands survive; anything else drops. */ +export const normalizeNotificationsRouteSearch = ( + input: Record<string, unknown>, +): 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<NotificationsRouteData> => { + 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..b3b39e7c4 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/screen.tsx @@ -0,0 +1,265 @@ +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"; + +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 ( + <div className="flex flex-col gap-4" role="status"> + <span className="sr-only">{t("loading")}</span> + {Array.from({ length: 4 }, (_, index) => ( + <div className="flex items-start gap-3 p-2" key={index}> + <Skeleton className="size-9 rounded-full" /> + <div className="flex flex-1 flex-col gap-2"> + <Skeleton className="h-4 w-3/4" /> + <Skeleton className="h-3 w-1/4" /> + </div> + </div> + ))} + </div> + ); + } + + if (query.isError) { + return ( + <div + className="flex flex-col items-center gap-3 rounded-lg border p-6 text-center" + role="alert" + > + <p className="text-muted-foreground leading-relaxed"> + {t("load_error")} + </p> + <Button onClick={() => void query.refetch()} variant="outline"> + {t("retry")} + </Button> + </div> + ); + } + + const items = query.data.pages.flatMap(page => page.items); + if (items.length === 0) { + return ( + <Empty className="rounded-lg border"> + <EmptyHeader> + <EmptyMedia variant="icon"> + <BellIcon /> + </EmptyMedia> + <EmptyTitle> + {search.filter === "unread" + ? t("empty_unread_title") + : t("empty_title")} + </EmptyTitle> + <EmptyDescription>{t("empty_desc")}</EmptyDescription> + </EmptyHeader> + </Empty> + ); + } + + return ( + <div className="flex flex-col gap-4"> + <ul aria-label={t("list_label")} className="flex flex-col gap-1"> + {items.map(item => ( + <NotificationItem + item={item} + key={item.id} + onArchive={item => void actions.archive(item)} + onOpen={actions.open} + onToggleRead={item => void actions.toggleRead(item)} + /> + ))} + </ul> + {query.hasNextPage ? ( + <Button + className="self-center" + disabled={query.isFetchingNextPage} + onClick={() => void query.fetchNextPage()} + variant="outline" + > + {query.isFetchingNextPage ? t("loading") : t("load_more")} + </Button> + ) : null} + </div> + ); +}; + +/** The full Notifications Center. Viewing it never marks anything read. */ +export const NotificationsRouteContent = ({ + description, + navigate, + search, + title, + userId, +}: NotificationsRouteData & { + navigate: (options: { search: NotificationsRouteSearch }) => unknown; + search: NotificationsRouteSearch; +}) => ( + <RouteMessages namespaces={NOTIFICATIONS_NAMESPACES}> + <NotificationsScreen + description={description} + navigate={navigate} + search={search} + title={title} + userId={userId} + /> + </RouteMessages> +); + +const NotificationsScreen = ({ + description, + navigate, + search, + title, + userId, +}: NotificationsRouteData & { + navigate: (options: { search: NotificationsRouteSearch }) => unknown; + search: NotificationsRouteSearch; +}) => { + 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 ( + <main className="container mx-auto flex max-w-3xl flex-col gap-6 p-4"> + <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> + <PageTitle className="mb-0" desc={description} h1={title} /> + <div className="flex shrink-0 items-center gap-2"> + <Button + disabled={state?.unread === 0} + onClick={() => void actions.markAllRead()} + variant="outline" + > + <CheckCheckIcon /> + {t("mark_all_read")} + </Button> + <Button + aria-label={t("settings")} + nativeButton={false} + render={<Link to={NOTIFICATION_SETTINGS_HREF} />} + size="icon" + title={t("settings")} + variant="ghost" + > + <Settings2Icon /> + </Button> + </div> + </div> + + <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> + <Tabs + onValueChange={value => + navigate({ + search: { + ...search, + filter: value === "unread" ? "unread" : undefined, + }, + }) + } + value={search.filter ?? ALL} + > + <TabsList> + <TabsTrigger value={ALL}>{t("filter_all")}</TabsTrigger> + <TabsTrigger value="unread"> + {t("filter_unread")} + {state && state.unread > 0 ? ` (${state.unread})` : ""} + </TabsTrigger> + </TabsList> + </Tabs> + + {categories.length > 1 ? ( + <Select + items={[ + { label: t("all_categories"), value: ALL }, + ...categories.map(([value, label]) => ({ label, value })), + ]} + onValueChange={value => + navigate({ + search: { + ...search, + category: + typeof value === "string" && value !== ALL + ? value + : undefined, + }, + }) + } + value={search.category ?? ALL} + > + <SelectTrigger aria-label={t("category_label")} className="sm:w-48"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value={ALL}>{t("all_categories")}</SelectItem> + {categories.map(([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : null} + </div> + + <NotificationsList search={search} userId={userId} /> + </main> + ); +}; 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..4b06ebb9b --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/settings-screen.tsx @@ -0,0 +1,417 @@ +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { LockIcon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useFormatter, useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; +import type { + NotificationPreferencesView, + NotificationPreferenceTypeView, +} from "@/views/notifications/notifications-query"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormCombobox } from "@/components/form/fields/combobox"; +import { AutoFormSelect } from "@/components/form/fields/select"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { PageTitle } from "@/components/ui/page-title"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + setNotificationSubscriptionInBrowser, + updateNotificationPreferencesInBrowser, +} from "@/views/notifications/notifications-actions"; +import { + notificationPreferencesQueryKey, + notificationPreferencesQueryOptions, + notificationSubscriptionsQueryKey, + notificationSubscriptionsQueryOptions, +} from "@/views/notifications/notifications-query"; + +type EmailMode = NotificationPreferenceTypeView["value"]["email"]; + +const HOURS = Array.from({ length: 24 }, (_, hour) => String(hour)); +const WEEKDAYS = ["0", "1", "2", "3", "4", "5", "6"]; + +const supportedTimeZones = (): string[] => { + try { + return Intl.supportedValuesOf("timeZone"); + } catch { + return ["UTC"]; + } +}; + +const browserTimeZone = (): string => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return "UTC"; + } +}; + +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: Parameters<typeof updateNotificationPreferencesInBrowser>[0], + optimistic?: ( + data: NotificationPreferencesView, + ) => NotificationPreferencesView, + ) => { + const key = notificationPreferencesQueryKey(userId); + if (optimistic) { + queryClient.setQueryData<NotificationPreferencesView>(key, data => + data ? optimistic(data) : data, + ); + } + try { + await updateNotificationPreferencesInBrowser(body); + toast.success(t("saved"), { description: t("saved_desc") }); + } catch { + toast.error(tErrors("title"), { + description: tErrors("internal_server_error"), + }); + } + await queryClient.invalidateQueries({ queryKey: key }); + }, + [queryClient, t, tErrors, userId], + ); +}; + +const PreferenceRow = ({ + onChange, + type, +}: { + onChange: (patch: { email?: EmailMode; inApp?: boolean }) => void; + type: NotificationPreferenceTypeView; +}) => { + const t = useTranslations("core.auth.settings.notifications"); + const switchId = `notification-in-app-${type.id}`; + const emailId = `notification-email-${type.id}`; + const modes = type.emailModes.map(mode => ({ + label: t(`email_modes.${mode}`), + value: mode, + })); + + return ( + <li className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between"> + <div className="flex min-w-0 flex-col gap-1"> + <span className="flex items-center gap-2 font-medium"> + {type.label} + {type.mandatory ? ( + <Badge variant="secondary"> + <LockIcon /> + {t("mandatory")} + </Badge> + ) : null} + </span> + {type.description ? ( + <span className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {type.description} + </span> + ) : null} + </div> + + <div className="flex shrink-0 flex-wrap items-center gap-4"> + <div className="flex items-center gap-2"> + <Switch + checked={type.value.inApp} + disabled={type.mandatory} + id={switchId} + onCheckedChange={checked => onChange({ inApp: checked })} + /> + <Label htmlFor={switchId}>{t("in_app")}</Label> + </div> + + {modes.length > 0 ? ( + <div className="flex items-center gap-2"> + <Label className="sr-only" htmlFor={emailId}> + {t("email")} + </Label> + <Select + items={modes} + onValueChange={value => { + if (value) onChange({ email: value as EmailMode }); + }} + value={type.value.email} + > + <SelectTrigger className="w-44" id={emailId}> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {modes.map(mode => ( + <SelectItem key={mode.value} value={mode.value}> + {mode.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + ) : ( + <span className="text-muted-foreground text-sm"> + {type.mandatory && type.value.email !== "none" + ? t(`email_modes.${type.value.email}`) + : t("email_unavailable")} + </span> + )} + </div> + </li> + ); +}; + +const ScheduleForm = ({ + preferences, + userId, +}: { + preferences: NotificationPreferencesView; + userId: number; +}) => { + const t = useTranslations("core.auth.settings.notifications.schedule"); + const format = useFormatter(); + const save = usePreferenceSaver(userId); + const zones = React.useMemo(() => { + const list = supportedTimeZones(); + + return list.includes("UTC") ? list : ["UTC", ...list]; + }, []); + + const formSchema = z.object({ + timeZone: z + .enum(zones as [string, ...string[]]) + .default(preferences.timeZone ?? browserTimeZone()), + digestHour: z + .enum(HOURS as [string, ...string[]]) + .default(String(preferences.digestHour)), + digestWeekday: z + .enum(WEEKDAYS as [string, ...string[]]) + .default(String(preferences.digestWeekday)), + }); + + const onSubmit: AutoFormOnSubmit<typeof formSchema> = async values => { + await save({ + digestHour: Number(values.digestHour), + digestWeekday: Number(values.digestWeekday), + timeZone: values.timeZone, + }); + }; + + return ( + <AutoForm + fields={[ + { + id: "timeZone", + component: props => ( + <AutoFormCombobox + description={t("time_zone_desc")} + label={t("time_zone")} + {...props} + /> + ), + }, + { + id: "digestHour", + component: props => ( + <AutoFormSelect + description={t("hour_desc")} + label={t("hour")} + labels={HOURS.map(hour => ({ + label: `${hour.padStart(2, "0")}:00`, + value: hour, + }))} + {...props} + /> + ), + }, + { + id: "digestWeekday", + component: props => ( + <AutoFormSelect + description={t("weekday_desc")} + label={t("weekday")} + labels={WEEKDAYS.map(day => ({ + // 7 January 2024 was a Sunday - day 0. + label: format.dateTime( + new Date(Date.UTC(2024, 0, 7 + Number(day))), + { timeZone: "UTC", weekday: "long" }, + ), + value: day, + }))} + {...props} + /> + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("submit") }} + /> + ); +}; + +const SubscriptionsList = ({ userId }: { userId: number }) => { + const t = useTranslations("core.auth.settings.notifications.subscriptions"); + const queryClient = useQueryClient(); + const { data } = useSuspenseQuery( + notificationSubscriptionsQueryOptions({ userId }), + ); + + const clear = async (item: (typeof data)[number]) => { + try { + await setNotificationSubscriptionInBrowser({ + state: "none", + subject: { id: item.subjectId, type: item.subjectType }, + }); + toast.success(item.state === "muted" ? t("unmuted") : t("unfollowed"), { + description: item.label ?? item.subjectId, + }); + } catch { + toast.error(t("error")); + } + await queryClient.invalidateQueries({ + queryKey: notificationSubscriptionsQueryKey(userId), + }); + }; + + if (data.length === 0) { + return ( + <p className="text-muted-foreground text-sm leading-relaxed"> + {t("empty")} + </p> + ); + } + + return ( + <ul className="divide-y"> + {data.map(item => ( + <li + className="flex items-center justify-between gap-3 py-3" + key={`${item.subjectType}:${item.subjectId}`} + > + <span className="flex min-w-0 flex-col gap-1"> + <span className="truncate font-medium"> + {item.label ?? `${item.subjectType} #${item.subjectId}`} + </span> + <span className="text-muted-foreground text-sm"> + {item.state === "muted" ? t("muted") : t("following")} + </span> + </span> + <Button onClick={() => void clear(item)} size="sm" variant="outline"> + {item.state === "muted" ? t("unmute") : t("unfollow")} + </Button> + </li> + ))} + </ul> + ); +}; + +/** `/settings/notifications`: what reaches the user, where, and when. */ +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 save = usePreferenceSaver(userId); + + 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<string, { label: string; types: NotificationPreferenceTypeView[] }>()) + .values(), + ]; + + const change = ( + type: NotificationPreferenceTypeView, + patch: { email?: EmailMode; inApp?: boolean }, + ) => + void save({ types: { [type.id]: patch } }, current => ({ + ...current, + types: current.types.map(entry => + entry.id === type.id + ? { ...entry, value: { ...entry.value, ...patch } } + : entry, + ), + })); + + return ( + <div className="flex flex-col gap-6"> + <PageTitle + className="mb-0" + desc={t("desc")} + h1={t("title")} + subtitle={tSettings("title")} + /> + + {groups.map(group => ( + <Card key={group.label}> + <CardHeader> + <CardTitle> + <h2>{group.label}</h2> + </CardTitle> + </CardHeader> + <CardContent> + <ul className="divide-y"> + {group.types.map(type => ( + <PreferenceRow + key={type.id} + onChange={patch => change(type, patch)} + type={type} + /> + ))} + </ul> + </CardContent> + </Card> + ))} + + <Card> + <CardHeader> + <CardTitle> + <h2>{t("schedule.title")}</h2> + </CardTitle> + <CardDescription>{t("schedule.desc")}</CardDescription> + </CardHeader> + <CardContent> + <ScheduleForm preferences={data} userId={userId} /> + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle> + <h2>{t("subscriptions.title")}</h2> + </CardTitle> + <CardDescription>{t("subscriptions.desc")}</CardDescription> + </CardHeader> + <CardContent> + <SubscriptionsList userId={userId} /> + </CardContent> + </Card> + </div> + ); +}; 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..6cbff7732 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/state-sync.test.tsx @@ -0,0 +1,165 @@ +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; + +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 ( + <output>{state ? `${state.unread}@${state.revision}` : "none"}</output> + ); +}; + +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( + <QueryClientProvider client={queryClient}> + <VitNodeWebSocketProvider> + <NotificationStateSync fetchState={fetchState} /> + <Count /> + </VitNodeWebSocketProvider> + </QueryClientProvider>, + ); + + 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"); + + // A session request sent before those updates resolves late: ignored. + 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)); + + // Offline for a while: the server moved on without us. + 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", () => { + // Same origin for web and API: this deployment opens no WebSocket. + vi.stubEnv("VITNODE_API_URL", "http://localhost:3000"); + 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..7abb9af51 --- /dev/null +++ b/packages/vitnode/src/tanstack/notifications/state-sync.tsx @@ -0,0 +1,135 @@ +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"; + +/** How long a tab may sit in the background before it re-reads the count. */ +const STALE_AFTER_HIDDEN_MS = 30_000; +/** + * Without a socket - none configured, or it is down - the count is re-read at + * this pace while the tab is visible. One primary-key read per tab. + */ +const POLL_WITHOUT_SOCKET_MS = 60_000; +const OPEN = 1; + +/** + * Keeps the unread count in step for the signed-in user: + * + * - seeds it from the session payload; + * - applies every realtime state message, newest revision wins; + * - re-reads it when the socket (re)connects - messages sent while offline + * are not replayed - and when a long-hidden tab comes back; + * - without a socket, re-reads it every minute while the tab is visible; + * - refreshes open notification lists once per burst, not once per message. + */ +export const NotificationStateSync = ({ + fetchState = fetchNotificationState, +}: { + /** How the authoritative state is re-read. Replaceable for tests. */ + 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]); + + // Created in an effect, not during render: it closes over refs. + const coalescerRef = React.useRef<null | ReturnType<typeof createCoalescer>>( + 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.useCallback(async () => { + const current = userIdRef.current; + if (current === null) return; + try { + if (notificationStateStore.apply(current, await fetchState())) { + coalescerRef.current?.schedule(); + } + } catch { + // Offline or signed out; the next successful read corrects it. + } + }, [fetchState]); + + 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 === OPEN) void refresh(); + }, [readyState, refresh]); + + React.useEffect(() => { + if (readyState === OPEN || userId === null) return; + + const timer = setInterval(() => { + if (document.visibilityState === "visible") void refresh(); + }, POLL_WITHOUT_SOCKET_MS); + + return () => clearInterval(timer); + }, [readyState, refresh, 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); + }, [refresh]); + + 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 ( <> <NotificationListener /> + <NotificationStateSync /> <WebSocketAuthSync userId={socketUserIdFromSession(session)} /> </> ); diff --git a/packages/vitnode/src/tests/notifications.ts b/packages/vitnode/src/tests/notifications.ts new file mode 100644 index 000000000..0e8798b46 --- /dev/null +++ b/packages/vitnode/src/tests/notifications.ts @@ -0,0 +1,248 @@ +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, + NotificationSubjectDefinition, +} from "@/api/lib/notifications/registry"; +import type { EnvVitNode } from "@/api/middlewares/global.middleware"; +import type { NotificationEmailProps } from "@/emails/notification"; + +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<EnvVitNode>; + createUsers: (count: number, language?: string) => Promise<number[]>; + database: TestDatabaseHandle; + /** Runs queued tasks until nothing due is left. */ + drainQueue: (now?: Date) => Promise<number>; + /** A context on its own connection pool, for concurrent work. */ + forkContext: () => Context<EnvVitNode>; + /** Queries sent through any connection since the harness was created. */ + 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 = {}, + subjects = [], + types, +}: { + email?: boolean; + messages?: Record<string, unknown>; + subjects?: NotificationSubjectDefinition[]; + types: AnyNotificationTypeDefinition[]; +}): Promise<NotificationsHarness> => { + 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, name: "Member", root: false } as never); + + const realtime: RealtimeRecord[] = []; + const sentEmails: SentEmailRecord[] = []; + let emailFailure: Error | null = null; + const registry = createNotificationRegistry( + types.map(definition => ({ definition, pluginId: "@acme/test" })), + subjects.map(definition => ({ definition, pluginId: "@acme/test" })), + ); + const translator = createTranslator({ + locale: "en", + messages: { ...apiMessages, ...messages }, + }); + + const makeContext = (db: TestDatabaseHandle["db"]): Context<EnvVitNode> => { + const vars = new Map<string, unknown>(); + const c = { + get: (key: string) => vars.get(key), + set: (key: string, value: unknown) => vars.set(key, value), + } as unknown as Context<EnvVitNode>; + + 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, + 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<NotificationEmailProps>; + 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<SentEmailRecord, "idempotencyKey">, + 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("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, + 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..0c3528b1c --- /dev/null +++ b/packages/vitnode/src/tests/postgres.ts @@ -0,0 +1,98 @@ +import { + generateDrizzleJson, + generateMigration, +} from "drizzle-kit/api-postgres"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { describe } from "vitest"; + +/** + * Integration tests that need real Postgres semantics - row locks, `ON + * CONFLICT`, concurrent transactions - run against the server named here and + * are skipped without it: + * + * VITNODE_TEST_POSTGRES_URL=postgresql://root@localhost:5432/postgres pnpm test + * + * Each `createTestDatabase` call makes a throwaway database, so test files can + * run in parallel and never see each other's rows. + */ +export 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); +}; + +export type TestDatabase = ReturnType<typeof drizzle>; + +export interface TestDatabaseHandle { + /** A second, independent connection pool - for genuinely concurrent work. */ + connect: () => TestDatabase; + db: TestDatabase; + drop: () => Promise<void>; + url: string; +} + +const withDatabase = (url: string, database: string): string => { + const parsed = new URL(url); + parsed.pathname = `/${database}`; + + return parsed.toString(); +}; + +/** Creates a database holding exactly the tables in `schema`. */ +export const createTestDatabase = async ( + schema: Record<string, unknown>, + { onQuery }: { onQuery?: () => void } = {}, +): Promise<TestDatabaseHandle> => { + 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<typeof postgres>[] = []; + 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(); + }, + url, + }; +}; 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/core/system/notifications/deliveries-content.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/deliveries-content.tsx new file mode 100644 index 000000000..070e48bd4 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/deliveries-content.tsx @@ -0,0 +1,328 @@ +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; +import { InboxIcon, RotateCwIcon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Label } from "@/components/ui/label"; +import { + NativeSelect, + NativeSelectOption, +} from "@/components/ui/native-select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { TooltipWithContent } from "@/components/ui/tooltip"; + +import type { NotificationsAdminActions } from "./notifications-mutations"; +import type { AdminNotificationDelivery } from "./notifications-query"; +import type { NotificationDeliveryStatus } from "./statuses"; + +import { DeliveryStatusBadge } from "./delivery-status-badge"; +import { notificationDeliveriesQueryOptions } from "./notifications-query"; +import { NOTIFICATION_DELIVERY_STATUSES } from "./statuses"; + +const ALL = "all"; + +const isDeliveryStatus = (value: string): value is NotificationDeliveryStatus => + (NOTIFICATION_DELIVERY_STATUSES as readonly string[]).includes(value); + +const RetryDeliveryButton = ({ + id, + onRetry, +}: { + id: number; + onRetry: NotificationsAdminActions["retryDelivery"]; +}) => { + const t = useTranslations("admin.system.notifications.deliveries.retry"); + const tError = useTranslations("core.global.errors"); + const [, formAction, isPending] = React.useActionState(async () => { + const mutation = await onRetry(id); + + if (mutation.error !== undefined) { + toast.error(tError("title"), { + description: + mutation.status === 409 + ? t("conflict") + : tError("internal_server_error"), + }); + + return null; + } + + toast.success(t("success"), { description: t("success_desc", { id }) }); + + return null; + }, null); + + return ( + <form action={formAction}> + <TooltipWithContent text={t("label", { id })}> + <Button + aria-label={t("label", { id })} + isLoading={isPending} + size="icon" + type="submit" + variant="ghost" + > + <RotateCwIcon aria-hidden /> + </Button> + </TooltipWithContent> + </form> + ); +}; + +const DeliveryRow = ({ + canManage, + delivery, + onRetry, +}: { + canManage: boolean; + delivery: AdminNotificationDelivery; + onRetry: NotificationsAdminActions["retryDelivery"]; +}) => { + const t = useTranslations("admin.system.notifications.deliveries"); + const problem = delivery.lastError ?? delivery.skipReason; + + return ( + <TableRow> + <TableCell className="tabular-nums">{delivery.id}</TableCell> + <TableCell> + <div className="flex max-w-xs flex-col gap-1"> + <span className="truncate font-mono text-xs"> + {delivery.type ?? (delivery.mode === "test" ? t("test_type") : "—")} + </span> + <span className="text-muted-foreground text-xs"> + {t("items", { count: delivery.itemCount })} + </span> + </div> + </TableCell> + <TableCell>{t(`modes.${delivery.mode}`)}</TableCell> + <TableCell> + <DeliveryStatusBadge status={delivery.status} /> + </TableCell> + <TableCell className="tabular-nums"> + {delivery.attempts}/{delivery.maxAttempts} + </TableCell> + <TableCell> + {problem ? ( + <span + className={ + delivery.lastError + ? "text-destructive line-clamp-2 max-w-xs text-sm whitespace-normal" + : "text-muted-foreground line-clamp-2 max-w-xs text-sm whitespace-normal" + } + title={problem} + > + {problem} + </span> + ) : ( + <span className="text-muted-foreground">—</span> + )} + </TableCell> + <TableCell className="tabular-nums"> + {t("user_id", { id: delivery.userId })} + </TableCell> + <TableCell> + <DateFormat date={delivery.updatedAt} /> + </TableCell> + <TableCell className="text-end"> + {canManage && delivery.status === "failed" ? ( + <RetryDeliveryButton id={delivery.id} onRetry={onRetry} /> + ) : null} + </TableCell> + </TableRow> + ); +}; + +const DeliveriesSkeleton = () => ( + <div aria-hidden className="flex flex-col gap-2"> + {["a", "b", "c", "d"].map(id => ( + <Skeleton className="h-10 w-full" key={id} /> + ))} + </div> +); + +const DeliveriesTable = ({ + canManage, + onRetry, + status, +}: { + canManage: boolean; + onRetry: NotificationsAdminActions["retryDelivery"]; + status?: NotificationDeliveryStatus; +}) => { + const t = useTranslations("admin.system.notifications.deliveries"); + const tPage = useTranslations("admin.system.notifications.load_error"); + const tError = useTranslations("core.global.errors"); + const query = useInfiniteQuery({ + ...notificationDeliveriesQueryOptions({ status }), + placeholderData: keepPreviousData, + }); + + if (query.isPending) return <DeliveriesSkeleton />; + + if (query.isError) { + return ( + <Alert variant="destructive"> + <AlertTitle>{tPage("title")}</AlertTitle> + <AlertDescription className="flex flex-col items-start gap-2"> + <p>{tPage("desc")}</p> + <Button + onClick={() => { + void query.refetch(); + }} + size="sm" + variant="outline" + > + {tError("try_again")} + </Button> + </AlertDescription> + </Alert> + ); + } + + const deliveries = query.data.pages.flatMap(page => page.items); + + if (deliveries.length === 0) { + return ( + <Empty> + <EmptyHeader> + <EmptyMedia variant="icon"> + <InboxIcon aria-hidden /> + </EmptyMedia> + <EmptyTitle>{t("empty.title")}</EmptyTitle> + <EmptyDescription>{t("empty.desc")}</EmptyDescription> + </EmptyHeader> + </Empty> + ); + } + + return ( + <div + aria-busy={query.isFetching} + className="flex flex-col items-center gap-4" + > + <Table> + <TableCaption className="sr-only">{t("caption")}</TableCaption> + <TableHeader> + <TableRow> + <TableHead>{t("id")}</TableHead> + <TableHead>{t("type")}</TableHead> + <TableHead>{t("mode")}</TableHead> + <TableHead>{t("state")}</TableHead> + <TableHead>{t("attempts")}</TableHead> + <TableHead>{t("error")}</TableHead> + <TableHead>{t("user")}</TableHead> + <TableHead>{t("updated")}</TableHead> + <TableHead> + <span className="sr-only">{t("actions")}</span> + </TableHead> + </TableRow> + </TableHeader> + <TableBody> + {deliveries.map(delivery => ( + <DeliveryRow + canManage={canManage} + delivery={delivery} + key={delivery.id} + onRetry={onRetry} + /> + ))} + </TableBody> + </Table> + + {query.hasNextPage ? ( + <Button + isLoading={query.isFetchingNextPage} + onClick={() => { + void query.fetchNextPage(); + }} + variant="outline" + > + {t("load_more")} + </Button> + ) : null} + </div> + ); +}; + +export const NotificationsDeliveriesCard = ({ + canManage, + onRetry, +}: { + canManage: boolean; + onRetry: NotificationsAdminActions["retryDelivery"]; +}) => { + const t = useTranslations("admin.system.notifications.deliveries"); + const tStatus = useTranslations("admin.system.notifications.status"); + const filterId = React.useId(); + const [status, setStatus] = React.useState<NotificationDeliveryStatus>(); + + return ( + <Card aria-labelledby="notifications-deliveries" role="region"> + <CardHeader> + <CardTitle> + <h2 className="text-balance" id="notifications-deliveries"> + {t("title")} + </h2> + </CardTitle> + <CardDescription className="leading-relaxed text-pretty"> + {t("desc")} + </CardDescription> + </CardHeader> + <CardContent className="flex flex-col gap-4"> + <div className="flex items-center gap-2"> + <Label className="font-normal" htmlFor={filterId}> + {t("filter")} + </Label> + <NativeSelect + id={filterId} + onChange={event => { + const next = event.target.value; + setStatus(isDeliveryStatus(next) ? next : undefined); + }} + size="sm" + value={status ?? ALL} + > + <NativeSelectOption value={ALL}>{t("all")}</NativeSelectOption> + {NOTIFICATION_DELIVERY_STATUSES.map(option => ( + <NativeSelectOption key={option} value={option}> + {tStatus(option)} + </NativeSelectOption> + ))} + </NativeSelect> + </div> + <DeliveriesTable + canManage={canManage} + onRetry={onRetry} + status={status} + /> + </CardContent> + </Card> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/delivery-status-badge.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/delivery-status-badge.tsx new file mode 100644 index 000000000..a1febd976 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/delivery-status-badge.tsx @@ -0,0 +1,59 @@ +import { + CircleCheckIcon, + CircleMinusIcon, + CircleXIcon, + ClockIcon, + LoaderIcon, +} from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { Badge } from "@/components/ui/badge"; + +import type { NotificationDeliveryStatus } from "./statuses"; + +export const DeliveryStatusBadge = ({ + status, +}: { + status: NotificationDeliveryStatus; +}) => { + const t = useTranslations("admin.system.notifications.status"); + + if (status === "sent") { + return ( + <Badge variant="success"> + <CircleCheckIcon aria-hidden /> {t("sent")} + </Badge> + ); + } + + if (status === "failed") { + return ( + <Badge variant="destructive"> + <CircleXIcon aria-hidden /> {t("failed")} + </Badge> + ); + } + + if (status === "sending") { + return ( + <Badge variant="secondary"> + <LoaderIcon aria-hidden className="motion-safe:animate-spin" />{" "} + {t("sending")} + </Badge> + ); + } + + if (status === "skipped") { + return ( + <Badge variant="outline"> + <CircleMinusIcon aria-hidden /> {t("skipped")} + </Badge> + ); + } + + return ( + <Badge variant="secondary"> + <ClockIcon aria-hidden /> {t("pending")} + </Badge> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/health-content.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/health-content.tsx new file mode 100644 index 000000000..3a03d15a9 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/health-content.tsx @@ -0,0 +1,205 @@ +import { + ClockIcon, + HourglassIcon, + type LucideIcon, + MailIcon, + SendIcon, + ZapIcon, +} from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +import type { + NotificationEmailHealth, + NotificationHealthSummary, + NotificationWorkerHealth, +} from "./health"; + +import { + NOTIFICATION_DELIVERY_STATUSES, + NOTIFICATION_EVENT_STATUSES, +} from "./statuses"; + +const EMAIL_BADGE = { + disabled: "secondary", + missing: "warning", + ready: "success", +} as const satisfies Record<NotificationEmailHealth, string>; + +const WORKER_BADGE = { + active: "success", + inactive: "destructive", + stale: "warning", +} as const satisfies Record<NotificationWorkerHealth, string>; + +const HealthCard = ({ + badge, + children, + description, + Icon, + title, +}: { + badge?: React.ReactNode; + children?: React.ReactNode; + description: string; + Icon: LucideIcon; + title: string; +}) => ( + <Card size="sm"> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-balance"> + <Icon aria-hidden className="text-muted-foreground size-4" /> + {title} + </CardTitle> + {badge ? <CardAction>{badge}</CardAction> : null} + <CardDescription className="leading-relaxed text-pretty"> + {description} + </CardDescription> + </CardHeader> + {children ? <CardContent>{children}</CardContent> : null} + </Card> +); + +const CountList = <S extends string>({ + counts, + label, + statuses, +}: { + counts: Record<S, number>; + label: (status: S) => string; + statuses: readonly S[]; +}) => ( + <dl className="grid grid-cols-2 gap-2"> + {statuses.map(status => ( + <div className="bg-muted flex flex-col gap-1 rounded-md p-2" key={status}> + <dt className="text-muted-foreground text-xs">{label(status)}</dt> + <dd className="text-lg font-semibold tabular-nums"> + {counts[status].toLocaleString()} + </dd> + </div> + ))} + </dl> +); + +export const NotificationsHealthContent = ({ + oldestPendingEventAt, + summary, +}: { + oldestPendingEventAt: Date | null | string; + summary: NotificationHealthSummary; +}) => { + const t = useTranslations("admin.system.notifications.health"); + const tStatus = useTranslations("admin.system.notifications.status"); + + return ( + <section + aria-labelledby="notifications-health" + className="flex flex-col gap-4" + > + <h2 + className="text-lg font-semibold text-balance" + id="notifications-health" + > + {t("title")} + </h2> + + {summary.warnings.length > 0 ? ( + <Alert variant="warning"> + <AlertTitle>{t("warnings.title")}</AlertTitle> + <AlertDescription> + <ul className="flex list-disc flex-col gap-1 ps-4"> + {summary.warnings.map(warning => ( + <li key={warning.kind}> + {"count" in warning + ? t(`warnings.${warning.kind}`, { count: warning.count }) + : t(`warnings.${warning.kind}`)} + </li> + ))} + </ul> + </AlertDescription> + </Alert> + ) : null} + + <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> + <HealthCard + badge={ + <Badge variant={EMAIL_BADGE[summary.email]}> + {t(`email.${summary.email}`)} + </Badge> + } + description={t(`email.${summary.email}_desc`)} + Icon={MailIcon} + title={t("email.title")} + /> + + <HealthCard + badge={ + <Badge variant={WORKER_BADGE[summary.worker]}> + {t(`worker.${summary.worker}`)} + </Badge> + } + description={t(`worker.${summary.worker}_desc`)} + Icon={ClockIcon} + title={t("worker.title")} + > + {summary.failedQueueTasks > 0 ? ( + <p className="text-destructive text-sm leading-relaxed"> + {t("failed_queue", { count: summary.failedQueueTasks })} + </p> + ) : null} + </HealthCard> + + <HealthCard + description={ + oldestPendingEventAt ? t("oldest.desc") : t("oldest.none_desc") + } + Icon={HourglassIcon} + title={t("oldest.title")} + > + <p className="text-lg font-semibold"> + {oldestPendingEventAt ? ( + <DateFormat date={oldestPendingEventAt} /> + ) : ( + t("oldest.none") + )} + </p> + </HealthCard> + + <HealthCard + description={t("events.desc")} + Icon={ZapIcon} + title={t("events.title")} + > + <CountList + counts={summary.events} + label={status => tStatus(status)} + statuses={NOTIFICATION_EVENT_STATUSES} + /> + </HealthCard> + + <HealthCard + description={t("deliveries.desc")} + Icon={SendIcon} + title={t("deliveries.title")} + > + <CountList + counts={summary.deliveries} + label={status => tStatus(status)} + statuses={NOTIFICATION_DELIVERY_STATUSES} + /> + </HealthCard> + </div> + </section> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/health.test.ts b/packages/vitnode/src/views/admin/views/core/system/notifications/health.test.ts new file mode 100644 index 000000000..391ae3e0f --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/health.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; + +import { summarizeNotificationHealth } from "./health"; + +const overview = ({ + adapterConfigured = true, + cronActive = true, + cronStale = false, + deliveries = {}, + enabled = true, + events = {}, + queue = {}, +}: { + adapterConfigured?: boolean; + cronActive?: boolean; + cronStale?: boolean; + deliveries?: Record<string, number>; + enabled?: boolean; + events?: Record<string, number>; + queue?: Record<string, number>; +} = {}) => ({ + email: { adapterConfigured, enabled }, + health: { + cronActive, + cronStale, + deliveries, + events, + oldestPendingEventAt: null, + queue, + }, +}); + +describe("summarizeNotificationHealth", () => { + it("reports a healthy installation with no warnings", () => { + const summary = summarizeNotificationHealth(overview()); + + expect(summary.email).toBe("ready"); + expect(summary.worker).toBe("active"); + expect(summary.warnings).toEqual([]); + }); + + it("fills in zero for every status the API left out", () => { + const summary = summarizeNotificationHealth( + overview({ deliveries: { sent: 4 }, events: { completed: 2 } }), + ); + + expect(summary.events).toEqual({ + completed: 2, + failed: 0, + pending: 0, + processing: 0, + }); + expect(summary.deliveries).toEqual({ + failed: 0, + pending: 0, + sending: 0, + sent: 4, + skipped: 0, + }); + }); + + it("warns when cron is missing, before anything else", () => { + const summary = summarizeNotificationHealth( + overview({ + adapterConfigured: false, + cronActive: false, + cronStale: true, + }), + ); + + expect(summary.worker).toBe("inactive"); + expect(summary.warnings.map(warning => warning.kind)).toEqual([ + "cron_inactive", + "email_adapter_missing", + ]); + }); + + it("calls an active but quiet cron stale", () => { + const summary = summarizeNotificationHealth(overview({ cronStale: true })); + + expect(summary.worker).toBe("stale"); + expect(summary.warnings).toEqual([{ kind: "cron_stale" }]); + }); + + it("treats a configured adapter with email turned off as disabled, not broken", () => { + const summary = summarizeNotificationHealth(overview({ enabled: false })); + + expect(summary.email).toBe("disabled"); + expect(summary.warnings).toEqual([]); + }); + + it("counts failures with their totals", () => { + const summary = summarizeNotificationHealth( + overview({ deliveries: { failed: 3 }, events: { failed: 1 } }), + ); + + expect(summary.warnings).toEqual([ + { count: 1, kind: "failed_events" }, + { count: 3, kind: "failed_deliveries" }, + ]); + }); + + it("adds up failed notification queue tasks across task names", () => { + const summary = summarizeNotificationHealth( + overview({ + queue: { + "notifications-email:failed": 2, + "notifications-email:pending": 5, + "notifications-fanout:failed": 1, + }, + }), + ); + + expect(summary.failedQueueTasks).toBe(3); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/health.ts b/packages/vitnode/src/views/admin/views/core/system/notifications/health.ts new file mode 100644 index 000000000..341939564 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/health.ts @@ -0,0 +1,94 @@ +import type { AdminNotificationsOverview } from "./notifications-query"; +import type { + NotificationDeliveryStatus, + NotificationEventStatus, +} from "./statuses"; + +import { + NOTIFICATION_DELIVERY_STATUSES, + NOTIFICATION_EVENT_STATUSES, +} from "./statuses"; + +export type NotificationEmailHealth = "disabled" | "missing" | "ready"; +export type NotificationWorkerHealth = "active" | "inactive" | "stale"; + +export type NotificationHealthWarning = + | { count: number; kind: "failed_deliveries" } + | { count: number; kind: "failed_events" } + | { kind: "cron_inactive" } + | { kind: "cron_stale" } + | { kind: "email_adapter_missing" }; + +export interface NotificationHealthSummary { + deliveries: Record<NotificationDeliveryStatus, number>; + email: NotificationEmailHealth; + events: Record<NotificationEventStatus, number>; + failedQueueTasks: number; + warnings: NotificationHealthWarning[]; + worker: NotificationWorkerHealth; +} + +/** Every known status with a count - the API leaves out statuses with no rows. */ +const fillCounts = <S extends string>( + statuses: readonly S[], + counts: Record<string, number>, +): Record<S, number> => + Object.fromEntries( + statuses.map(status => [status, counts[status] ?? 0]), + ) as Record<S, number>; + +/** + * What the health cards and the warning banner show, derived from the + * overview's raw aggregates. Warnings are ordered by how much they block: + * nothing moves without cron, nothing is emailed without an adapter, and + * failures come last because they are already recoverable from this screen. + */ +export const summarizeNotificationHealth = ({ + email, + health, +}: Pick< + AdminNotificationsOverview, + "email" | "health" +>): NotificationHealthSummary => { + const events = fillCounts(NOTIFICATION_EVENT_STATUSES, health.events); + const deliveries = fillCounts( + NOTIFICATION_DELIVERY_STATUSES, + health.deliveries, + ); + const failedQueueTasks = Object.entries(health.queue) + .filter(([key]) => key.endsWith(":failed")) + .reduce((total, [, count]) => total + count, 0); + + const worker: NotificationWorkerHealth = !health.cronActive + ? "inactive" + : health.cronStale + ? "stale" + : "active"; + const emailHealth: NotificationEmailHealth = !email.adapterConfigured + ? "missing" + : email.enabled + ? "ready" + : "disabled"; + + const warnings: NotificationHealthWarning[] = []; + if (worker === "inactive") warnings.push({ kind: "cron_inactive" }); + if (worker === "stale") warnings.push({ kind: "cron_stale" }); + if (emailHealth === "missing") { + warnings.push({ kind: "email_adapter_missing" }); + } + if (events.failed > 0) { + warnings.push({ count: events.failed, kind: "failed_events" }); + } + if (deliveries.failed > 0) { + warnings.push({ count: deliveries.failed, kind: "failed_deliveries" }); + } + + return { + deliveries, + email: emailHealth, + events, + failedQueueTasks, + warnings, + worker, + }; +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/maintenance-content.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/maintenance-content.tsx new file mode 100644 index 000000000..6917c93e4 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/maintenance-content.tsx @@ -0,0 +1,216 @@ +import { + BrushCleaningIcon, + MailCheckIcon, + SearchCheckIcon, + WrenchIcon, +} 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 { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; + +import type { NotificationsAdminActions } from "./notifications-mutations"; + +export const NotificationsMaintenanceCard = ({ + actions, + canManage, + emailConfigured, + retentionDays, +}: { + actions: Pick< + NotificationsAdminActions, + "cleanup" | "reconcile" | "sendTestEmail" + >; + canManage: boolean; + emailConfigured: boolean; + retentionDays: number; +}) => { + const t = useTranslations("admin.system.notifications.maintenance"); + const tError = useTranslations("core.global.errors"); + const [mismatched, setMismatched] = React.useState<null | number>(null); + + const showError = () => { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + }; + + const [, sendTestEmail, isSendingTestEmail] = React.useActionState( + async () => { + const mutation = await actions.sendTestEmail(); + + if (mutation.error !== undefined) { + if (mutation.status === 400) { + toast.error(tError("title"), { + description: t("test_email.not_configured"), + }); + } else showError(); + + return null; + } + + toast.success(t("test_email.success"), { + description: t("test_email.success_desc", { + id: mutation.data.deliveryId, + }), + }); + + return null; + }, + null, + ); + + const [, checkCounts, isChecking] = React.useActionState(async () => { + const mutation = await actions.reconcile({ dryRun: true }); + + if (mutation.error !== undefined) { + showError(); + + return null; + } + + setMismatched(mutation.data.mismatched); + + return null; + }, null); + + const [, cleanup, isCleaning] = React.useActionState(async () => { + const mutation = await actions.cleanup(); + + if (mutation.error !== undefined) { + showError(); + + return null; + } + + toast.success(t("cleanup.success"), { + description: t("cleanup.success_desc"), + }); + + return null; + }, null); + + return ( + <Card aria-labelledby="notifications-maintenance" role="region"> + <CardHeader> + <CardTitle> + <h2 + className="flex items-center gap-2 text-balance" + id="notifications-maintenance" + > + <WrenchIcon aria-hidden className="text-muted-foreground size-4" /> + {t("title")} + </h2> + </CardTitle> + <CardDescription className="leading-relaxed text-pretty"> + {canManage ? t("desc") : t("read_only")} + </CardDescription> + </CardHeader> + + {canManage ? ( + <CardContent className="flex flex-col gap-6"> + <div className="flex flex-col gap-2"> + <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {t("test_email.desc")} + </p> + <form action={sendTestEmail}> + <Button + disabled={!emailConfigured} + isLoading={isSendingTestEmail} + type="submit" + variant="outline" + > + <MailCheckIcon aria-hidden /> + {t("test_email.label")} + </Button> + </form> + {emailConfigured ? null : ( + <p className="text-muted-foreground text-sm leading-relaxed"> + {t("test_email.not_configured")} + </p> + )} + </div> + + <Separator /> + + <div className="flex flex-col gap-2"> + <h3 className="font-medium">{t("reconcile.title")}</h3> + <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {t("reconcile.desc")} + </p> + <div className="flex flex-wrap gap-2"> + <form action={checkCounts}> + <Button isLoading={isChecking} type="submit" variant="outline"> + <SearchCheckIcon aria-hidden /> + {t("reconcile.check")} + </Button> + </form> + <ConfirmActionAlertDialog + description={t("reconcile.confirm_desc")} + onSubmit={async ({ onClose }) => { + const mutation = await actions.reconcile({ dryRun: false }); + + if (mutation.error !== undefined) { + showError(); + + return; + } + + onClose(); + setMismatched(null); + toast.success(t("reconcile.fixed"), { + description: t("reconcile.fixed_desc", { + count: mutation.data.corrected, + }), + }); + }} + submitVariant="default" + textSubmit={t("reconcile.confirm_submit")} + title={t("reconcile.confirm_title")} + > + <Button variant="outline">{t("reconcile.fix")}</Button> + </ConfirmActionAlertDialog> + </div> + <p aria-live="polite" className="text-sm leading-relaxed"> + {mismatched === null + ? null + : mismatched === 0 + ? t("reconcile.result_ok") + : t("reconcile.result_mismatched", { count: mismatched })} + </p> + </div> + + <Separator /> + + <div className="flex flex-col gap-2"> + <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {t("retention", { days: retentionDays })} + </p> + <form action={cleanup}> + <Button isLoading={isCleaning} type="submit" variant="outline"> + <BrushCleaningIcon aria-hidden /> + {t("cleanup.label")} + </Button> + </form> + </div> + </CardContent> + ) : ( + <CardContent> + <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {t("retention", { days: retentionDays })} + </p> + </CardContent> + )} + </Card> + ); +}; 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..03961d821 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/notifications-content.tsx @@ -0,0 +1,60 @@ +import type { NotificationsAdminActions } from "./notifications-mutations"; +import type { AdminNotificationsOverview } from "./notifications-query"; + +import { NotificationsDeliveriesCard } from "./deliveries-content"; +import { summarizeNotificationHealth } from "./health"; +import { NotificationsHealthContent } from "./health-content"; +import { NotificationsMaintenanceCard } from "./maintenance-content"; +import { NotificationsSettingsCard } from "./settings-form"; +import { NotificationsTypesCard } 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 summary = summarizeNotificationHealth(data); + + return ( + <div className="flex flex-col gap-8"> + <NotificationsHealthContent + oldestPendingEventAt={data.health.oldestPendingEventAt} + summary={summary} + /> + + <div className="grid gap-6 xl:grid-cols-2"> + <NotificationsSettingsCard + canEdit={canEdit} + onSave={actions.updateSettings} + settings={data.settings} + /> + <NotificationsMaintenanceCard + actions={actions} + canManage={canManage} + emailConfigured={summary.email === "ready"} + retentionDays={data.settings.retentionDays} + /> + </div> + + <NotificationsTypesCard + canEdit={canEdit} + onUpdate={actions.updateTypePolicy} + types={data.types} + /> + + <NotificationsDeliveriesCard + canManage={canManage} + onRetry={actions.retryDelivery} + /> + </div> + ); +}; 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..2bf9fa86b --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/notifications-mutations.ts @@ -0,0 +1,153 @@ +import type { NotificationEmailMode } from "@/lib/notifications/types"; + +import { CONFIG_PLUGIN } from "@/config"; +import { fetcherClient } from "@/lib/fetcher-client"; + +import type { AdminNotificationSettings } from "./notifications-query"; + +/** What every write on this screen resolves to - never a throw. */ +export type NotificationsMutationResult<T> = + | { data: T; error?: never; status?: never } + | { data?: never; error: string; status: number }; + +export interface NotificationTypePolicyPatch { + allowEmail?: boolean; + email?: NotificationEmailMode; + enabled?: boolean; + inApp?: boolean; +} + +/** The writes the screen can perform, as the host hands them in. */ +export interface NotificationsAdminActions { + cleanup: () => Promise<NotificationsMutationResult<{ success: boolean }>>; + reconcile: (body: { + dryRun: boolean; + }) => Promise< + NotificationsMutationResult<{ corrected: number; mismatched: number }> + >; + retryDelivery: ( + id: number, + ) => Promise<NotificationsMutationResult<{ success: boolean }>>; + sendTestEmail: () => Promise< + NotificationsMutationResult<{ deliveryId: number }> + >; + updateSettings: ( + body: Partial<AdminNotificationSettings>, + ) => Promise<NotificationsMutationResult<AdminNotificationSettings>>; + updateTypePolicy: ( + type: string, + body: NotificationTypePolicyPatch, + ) => Promise<NotificationsMutationResult<{ success: boolean }>>; +} + +const FAILED = { error: "Request failed", status: 500 } as const; + +const failure = async (response: { + status: number; + text: () => Promise<string>; +}) => ({ error: await response.text(), status: response.status }); + +// Each call catches: the client throws on a 500 with the server's own text, +// which the server has already logged. The screen only needs to know it failed. +export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { + cleanup: async () => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/notifications", + options: { credentials: "include" }, + path: "/cleanup", + }); + if (!response.ok) return await failure(response); + + return { data: await response.json() }; + } catch { + return FAILED; + } + }, + reconcile: async body => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body }, + method: "post", + module: "admin/notifications", + options: { credentials: "include" }, + path: "/reconcile", + }); + if (!response.ok) return await failure(response); + + return { data: await response.json() }; + } catch { + return FAILED; + } + }, + retryDelivery: async id => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { id: id } }, + method: "post", + module: "admin/notifications", + options: { credentials: "include" }, + path: "/deliveries/{id}/retry", + }); + 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", + options: { credentials: "include" }, + path: "/test-email", + }); + if (!response.ok) return await failure(response); + + return { data: await response.json() }; + } catch { + return FAILED; + } + }, + updateSettings: async body => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body }, + method: "put", + module: "admin/notifications", + options: { credentials: "include" }, + path: "/settings", + }); + 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", + options: { credentials: "include" }, + 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..3935f3197 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/notifications-query.ts @@ -0,0 +1,174 @@ +import { infiniteQueryOptions, 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"; + +import type { NotificationDeliveryStatus } from "./statuses"; + +export type NotificationDeliveryMode = + "daily" | "immediate" | "test" | "weekly"; + +export interface AdminNotificationSettings { + emailBatchSize: number; + emailConcurrency: number; + emailEnabled: boolean; + fanoutBatchSize: number; + retentionDays: number; +} + +export interface AdminNotificationTypePolicy { + allowEmail: boolean; + email: NotificationEmailMode | null; + enabled: boolean; + inApp: boolean | null; +} + +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 { + email: { adapterConfigured: boolean; enabled: boolean }; + health: { + cronActive: boolean; + cronStale: boolean; + deliveries: Record<string, number>; + events: Record<string, number>; + oldestPendingEventAt: Date | null | string; + queue: Record<string, number>; + }; + settings: AdminNotificationSettings; + types: AdminNotificationType[]; +} + +export interface AdminNotificationDelivery { + attempts: number; + availableAt: Date | string; + createdAt: Date | string; + id: number; + itemCount: number; + lastError: null | string; + maxAttempts: number; + mode: NotificationDeliveryMode; + providerMessageId: null | string; + sentAt: Date | null | string; + skipReason: null | string; + status: NotificationDeliveryStatus; + type: null | string; + updatedAt: Date | string; + userId: number; +} + +export interface AdminNotificationDeliveriesPage { + items: AdminNotificationDelivery[]; + nextCursor: null | number; +} + +export const DELIVERIES_PAGE_SIZE = 25; + +export const fetchNotificationsOverview = + async (): Promise<AdminNotificationsOverview> => { + 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 fetchNotificationDeliveries = async ({ + cursor, + signal, + status, +}: { + cursor: null | number; + signal?: AbortSignal; + status?: NotificationDeliveryStatus; +}): Promise<AdminNotificationDeliveriesPage> => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { + query: { + cursor: cursor ?? undefined, + limit: DELIVERIES_PAGE_SIZE, + status, + }, + }, + method: "get", + module: "admin/notifications", + options: { signal }, + path: "/deliveries", + }); + + if (!response.ok) { + throw new AdminRequestError( + response.status, + "the notification deliveries", + status ? `status=${status}` : undefined, + ); + } + + return await response.json(); +}; + +/** The root every cache entry of the notifications screen hangs off. */ +export const notificationsAdminQueryRoot = adminQueryRoot("notifications"); + +export const notificationsOverviewQueryKey = [ + ...notificationsAdminQueryRoot, + "overview", +] as const; + +export const notificationDeliveriesQueryRoot = [ + ...notificationsAdminQueryRoot, + "deliveries", +] as const; + +export const notificationsOverviewQueryOptions = () => + queryOptions({ + queryFn: async () => await fetchNotificationsOverview(), + queryKey: notificationsOverviewQueryKey, + retry: false, + /** {@link OPERATIONAL_STALE_TIME} - Health counts move while nobody touches anything. */ + staleTime: OPERATIONAL_STALE_TIME, + }); + +export const notificationDeliveriesQueryOptions = ({ + status, +}: { + status?: NotificationDeliveryStatus; +}) => + infiniteQueryOptions({ + getNextPageParam: (page: AdminNotificationDeliveriesPage) => + page.nextCursor ?? undefined, + initialPageParam: null as null | number, + queryFn: async ({ pageParam, signal }) => + await fetchNotificationDeliveries({ cursor: pageParam, signal, status }), + queryKey: [...notificationDeliveriesQueryRoot, status ?? "all"] as const, + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/settings-form.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/settings-form.tsx new file mode 100644 index 000000000..4db4d28d0 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/settings-form.tsx @@ -0,0 +1,189 @@ +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { + AutoFormOnSubmit, + ItemAutoFormComponentProps, +} from "@/components/form/auto-form"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormNumber } from "@/components/form/fields/number"; +import { AutoFormSwitch } from "@/components/form/fields/switch"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +import type { NotificationsAdminActions } from "./notifications-mutations"; +import type { AdminNotificationSettings } from "./notifications-query"; + +const NUMBER_FIELDS = [ + { id: "retentionDays", max: 3650, min: 1 }, + { id: "fanoutBatchSize", max: 5000, min: 10 }, + { id: "emailBatchSize", max: 500, min: 1 }, + { id: "emailConcurrency", max: 20, min: 1 }, +] as const; + +const SettingsReadOnly = ({ + settings, +}: { + settings: AdminNotificationSettings; +}) => { + const t = useTranslations("admin.system.notifications.settings"); + + return ( + <div className="flex flex-col gap-4"> + <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {t("read_only")} + </p> + <dl className="grid gap-4 sm:grid-cols-2"> + <div className="flex flex-col gap-1"> + <dt className="text-muted-foreground text-sm"> + {t("emailEnabled.label")} + </dt> + <dd className="font-medium"> + {settings.emailEnabled ? t("enabled") : t("disabled")} + </dd> + </div> + {NUMBER_FIELDS.map(({ id }) => ( + <div className="flex flex-col gap-1" key={id}> + <dt className="text-muted-foreground text-sm"> + {t(`${id}.label`)} + </dt> + <dd className="font-medium tabular-nums"> + {settings[id].toLocaleString()} + {id === "retentionDays" ? ` ${t("retentionDays.unit")}` : null} + </dd> + </div> + ))} + </dl> + </div> + ); +}; + +const SettingsForm = ({ + onSave, + settings, +}: { + onSave: NotificationsAdminActions["updateSettings"]; + settings: AdminNotificationSettings; +}) => { + const t = useTranslations("admin.system.notifications.settings"); + const tError = useTranslations("core.global.errors"); + + const formSchema = z.object({ + emailEnabled: z.boolean().default(settings.emailEnabled), + retentionDays: z + .number() + .int() + .min(1) + .max(3650) + .default(settings.retentionDays), + fanoutBatchSize: z + .number() + .int() + .min(10) + .max(5000) + .default(settings.fanoutBatchSize), + emailBatchSize: z + .number() + .int() + .min(1) + .max(500) + .default(settings.emailBatchSize), + emailConcurrency: z + .number() + .int() + .min(1) + .max(20) + .default(settings.emailConcurrency), + }); + + const onSubmit: AutoFormOnSubmit<typeof formSchema> = async values => { + const mutation = await onSave(values); + + if (mutation.error !== undefined) { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + + return; + } + + toast.success(t("success"), { description: t("success_desc") }); + }; + + return ( + <AutoForm + fields={[ + { + id: "emailEnabled", + component: props => ( + <AutoFormSwitch + {...props} + description={t("emailEnabled.desc")} + label={t("emailEnabled.label")} + /> + ), + }, + ...NUMBER_FIELDS.map(({ id, max, min }) => ({ + id, + component: (props: ItemAutoFormComponentProps) => ( + <AutoFormNumber + {...props} + description={t(`${id}.desc`)} + label={t(`${id}.label`)} + max={max} + min={min} + step={1} + unitLabel={ + id === "retentionDays" ? t("retentionDays.unit") : undefined + } + /> + ), + })), + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("submit") }} + /> + ); +}; + +export const NotificationsSettingsCard = ({ + canEdit, + onSave, + settings, +}: { + canEdit: boolean; + onSave: NotificationsAdminActions["updateSettings"]; + settings: AdminNotificationSettings; +}) => { + const t = useTranslations("admin.system.notifications.settings"); + + return ( + <Card aria-labelledby="notifications-settings" role="region"> + <CardHeader> + <CardTitle> + <h2 className="text-balance" id="notifications-settings"> + {t("title")} + </h2> + </CardTitle> + <CardDescription className="leading-relaxed text-pretty"> + {t("desc")} + </CardDescription> + </CardHeader> + <CardContent> + {canEdit ? ( + <SettingsForm onSave={onSave} settings={settings} /> + ) : ( + <SettingsReadOnly settings={settings} /> + )} + </CardContent> + </Card> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/statuses.ts b/packages/vitnode/src/views/admin/views/core/system/notifications/statuses.ts new file mode 100644 index 000000000..5ef201ee3 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/statuses.ts @@ -0,0 +1,18 @@ +export const NOTIFICATION_EVENT_STATUSES = [ + "pending", + "processing", + "completed", + "failed", +] as const; +export type NotificationEventStatus = + (typeof NOTIFICATION_EVENT_STATUSES)[number]; + +export const NOTIFICATION_DELIVERY_STATUSES = [ + "failed", + "pending", + "sending", + "sent", + "skipped", +] as const; +export type NotificationDeliveryStatus = + (typeof NOTIFICATION_DELIVERY_STATUSES)[number]; 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..04d401505 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/types-content.tsx @@ -0,0 +1,261 @@ +import { BellOffIcon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Label } from "@/components/ui/label"; +import { + NativeSelect, + NativeSelectOption, +} from "@/components/ui/native-select"; +import { Switch } from "@/components/ui/switch"; +import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types"; + +import type { + NotificationsAdminActions, + NotificationTypePolicyPatch, +} from "./notifications-mutations"; +import type { + AdminNotificationType, + AdminNotificationTypePolicy, +} from "./notifications-query"; + +const isEmailMode = ( + value: string, +): value is (typeof NOTIFICATION_EMAIL_MODES)[number] => + (NOTIFICATION_EMAIL_MODES as readonly string[]).includes(value); + +const SwitchField = ({ + checked, + disabled, + hint, + label, + onCheckedChange, +}: { + checked: boolean; + disabled: boolean; + hint?: string; + label: string; + onCheckedChange: (checked: boolean) => void; +}) => ( + <div className="flex flex-col gap-1"> + <Label className="font-normal"> + <Switch + checked={checked} + disabled={disabled} + onCheckedChange={value => { + onCheckedChange(value); + }} + size="sm" + /> + {label} + </Label> + {hint ? ( + <p className="text-muted-foreground text-xs leading-relaxed">{hint}</p> + ) : null} + </div> +); + +const TypeRow = ({ + canEdit, + onUpdate, + type, +}: { + canEdit: boolean; + onUpdate: NotificationsAdminActions["updateTypePolicy"]; + type: AdminNotificationType; +}) => { + const t = useTranslations("admin.system.notifications.types"); + const tError = useTranslations("core.global.errors"); + const selectId = React.useId(); + const [isPending, startTransition] = React.useTransition(); + const [policy, applyOptimistic] = React.useOptimistic( + type.policy, + ( + current: AdminNotificationTypePolicy, + patch: NotificationTypePolicyPatch, + ): AdminNotificationTypePolicy => ({ ...current, ...patch }), + ); + + 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.id }), + }); + }); + }; + + const isLocked = !canEdit || isPending; + const defaultInApp = policy.inApp ?? type.defaults.inApp; + const defaultEmail = policy.email ?? type.defaults.email; + + return ( + <li + aria-busy={isPending} + className="flex flex-col gap-4 border-b py-4 last:border-b-0 lg:flex-row lg:items-start lg:justify-between" + > + <div className="flex min-w-0 flex-col gap-2"> + <div className="flex flex-col gap-1"> + <h3 className="font-mono text-sm font-medium break-all">{type.id}</h3> + <p className="text-muted-foreground text-sm"> + {type.pluginId} · {type.category} ·{" "} + {t("version", { version: type.version })} + </p> + </div> + <ul className="flex flex-wrap gap-2"> + <li> + <Badge variant="secondary">{t("in_app")}</Badge> + </li> + {type.emailSupported ? ( + <li> + <Badge variant={type.emailAvailable ? "success" : "warning"}> + {type.emailAvailable ? t("email") : t("email_unavailable")} + </Badge> + </li> + ) : null} + {type.grouped ? ( + <li> + <Badge variant="outline">{t("grouped")}</Badge> + </li> + ) : null} + {type.mandatory ? ( + <li> + <Badge variant="outline">{t("mandatory")}</Badge> + </li> + ) : null} + </ul> + </div> + + <div className="grid shrink-0 gap-4 sm:grid-cols-2 lg:w-md"> + <SwitchField + checked={type.mandatory || policy.enabled} + disabled={isLocked || type.mandatory} + hint={type.mandatory ? t("enabled_mandatory") : undefined} + label={t("enabled")} + onCheckedChange={enabled => { + save({ enabled }); + }} + /> + <SwitchField + checked={defaultInApp} + disabled={isLocked} + label={t("default_in_app")} + onCheckedChange={inApp => { + save({ inApp }); + }} + /> + {type.emailSupported ? ( + <> + <SwitchField + checked={policy.allowEmail} + disabled={isLocked} + label={t("allow_email")} + onCheckedChange={allowEmail => { + save({ allowEmail }); + }} + /> + <div className="flex flex-col gap-2"> + <Label className="font-normal" htmlFor={selectId}> + {t("default_email")} + </Label> + <NativeSelect + className="w-full" + disabled={isLocked || !policy.allowEmail} + id={selectId} + onChange={event => { + const email = event.target.value; + if (isEmailMode(email)) save({ email }); + }} + size="sm" + value={defaultEmail} + > + {NOTIFICATION_EMAIL_MODES.map(mode => ( + <NativeSelectOption key={mode} value={mode}> + {t(`email_modes.${mode}`)} + </NativeSelectOption> + ))} + </NativeSelect> + </div> + </> + ) : null} + </div> + </li> + ); +}; + +export const NotificationsTypesCard = ({ + canEdit, + onUpdate, + types, +}: { + canEdit: boolean; + onUpdate: NotificationsAdminActions["updateTypePolicy"]; + types: AdminNotificationType[]; +}) => { + const t = useTranslations("admin.system.notifications.types"); + + return ( + <Card aria-labelledby="notifications-types" role="region"> + <CardHeader> + <CardTitle> + <h2 className="text-balance" id="notifications-types"> + {t("title")} + </h2> + </CardTitle> + <CardDescription className="leading-relaxed text-pretty"> + {canEdit ? t("desc") : `${t("desc")} ${t("read_only")}`} + </CardDescription> + </CardHeader> + <CardContent> + {types.length === 0 ? ( + <Empty> + <EmptyHeader> + <EmptyMedia variant="icon"> + <BellOffIcon aria-hidden /> + </EmptyMedia> + <EmptyTitle>{t("empty.title")}</EmptyTitle> + <EmptyDescription>{t("empty.desc")}</EmptyDescription> + </EmptyHeader> + </Empty> + ) : ( + <ul className="flex flex-col"> + {types.map(type => ( + <TypeRow + canEdit={canEdit} + key={type.id} + onUpdate={onUpdate} + type={type} + /> + ))} + </ul> + )} + </CardContent> + </Card> + ); +}; 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/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..93379890c 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,8 @@ export interface HeaderLayoutContentProps extends Omit< mobileUser?: React.ReactNode; moreNavigationLabel: string; navigation: HeaderNavItem[]; + /** Shown on every screen size, before the user menu. */ + notifications?: React.ReactNode; user?: React.ReactNode; } @@ -23,6 +25,7 @@ export const HeaderLayoutContent = ({ mobileUser, moreNavigationLabel, navigation, + notifications, user, ...props }: HeaderLayoutContentProps) => ( @@ -43,6 +46,7 @@ export const HeaderLayoutContent = ({ /> <div className="ms-auto flex shrink-0 items-center gap-2"> + {notifications} <div className={cn( "flex items-center gap-2", diff --git a/packages/vitnode/src/views/notifications/coalesce.test.ts b/packages/vitnode/src/views/notifications/coalesce.test.ts new file mode 100644 index 000000000..86ed9df89 --- /dev/null +++ b/packages/vitnode/src/views/notifications/coalesce.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createCoalescer } from "./coalesce"; + +describe("createCoalescer", () => { + 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..27cdbe879 --- /dev/null +++ b/packages/vitnode/src/views/notifications/coalesce.ts @@ -0,0 +1,34 @@ +/** + * Collapses a burst of calls into one: it fires `wait` ms after the last call, + * but never later than `maxWait` ms after the first, so a steady stream of + * notifications still refreshes the list instead of postponing it forever. + */ +export const createCoalescer = ( + run: () => void, + { maxWait, wait }: { maxWait: number; wait: number }, +) => { + let timer: ReturnType<typeof setTimeout> | 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..cf3f2d916 --- /dev/null +++ b/packages/vitnode/src/views/notifications/notification-item.tsx @@ -0,0 +1,150 @@ +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; + /** + * Called on activation. It marks the item read and navigates; for a + * modified click (new tab) the browser follows the link itself and this + * only marks it read. + */ + 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; + +export const NotificationItem = ({ + item, + onArchive, + onOpen, + onToggleRead, + variant = "full", +}: NotificationItemProps) => { + const t = useTranslations("core.global.notifications"); + const actor = item.actors.at(0); + const linkable = item.available && item.target !== null; + + const content = ( + <> + <span className="relative shrink-0"> + {actor && item.available ? ( + <Avatar size={36} user={actor} /> + ) : ( + <span + aria-hidden + className="bg-muted text-muted-foreground flex size-9 items-center justify-center rounded-full" + > + {item.available ? ( + <MailIcon className="size-4" /> + ) : ( + <BellOffIcon className="size-4" /> + )} + </span> + )} + </span> + <span className="flex min-w-0 flex-1 flex-col gap-1"> + <span + className={cn( + "text-sm leading-relaxed text-pretty", + item.unread + ? "text-foreground font-medium" + : "text-muted-foreground", + !item.available && "italic", + )} + > + {item.title} + </span> + {item.body && variant === "full" ? ( + <span className="text-muted-foreground line-clamp-2 text-sm leading-relaxed"> + {item.body} + </span> + ) : null} + <span className="text-muted-foreground text-xs"> + <DateFormat date={item.lastActivityAt} /> + </span> + </span> + {item.unread ? ( + <span className="flex shrink-0 items-center pt-2"> + <span aria-hidden className="bg-primary size-2 rounded-full" /> + <span className="sr-only">{t("unread")}</span> + </span> + ) : 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"; + + return ( + <li + className={cn( + "flex items-start gap-1 rounded-lg", + item.unread && "bg-primary/5", + )} + > + {linkable ? ( + <a + className={itemClassName} + href={item.target ?? undefined} + onClick={event => { + const newTab = isModifiedClick(event); + if (!newTab) event.preventDefault(); + onOpen(item, { newTab }); + }} + > + {content} + </a> + ) : ( + <button + className={itemClassName} + onClick={() => onOpen(item, { newTab: false })} + type="button" + > + {content} + </button> + )} + + {variant === "full" && (onToggleRead || onArchive) ? ( + <span className="flex shrink-0 items-center gap-1 p-1"> + {onToggleRead ? ( + <Button + aria-label={item.unread ? t("mark_read") : t("mark_unread")} + onClick={() => onToggleRead(item)} + size="icon-sm" + title={item.unread ? t("mark_read") : t("mark_unread")} + variant="ghost" + > + {item.unread ? <MailOpenIcon /> : <MailIcon />} + </Button> + ) : null} + {onArchive ? ( + <Button + aria-label={t("archive")} + onClick={() => onArchive(item)} + size="icon-sm" + title={t("archive")} + variant="ghost" + > + <ArchiveIcon /> + </Button> + ) : null} + </span> + ) : null} + </li> + ); +}; 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..09c468a31 --- /dev/null +++ b/packages/vitnode/src/views/notifications/notification-state-store.test.ts @@ -0,0 +1,39 @@ +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); + // A WebSocket update overtook the session request that is now arriving. + 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..1c38fe227 --- /dev/null +++ b/packages/vitnode/src/views/notifications/notification-state-store.ts @@ -0,0 +1,80 @@ +import React from "react"; + +import type { NotificationState } from "@/lib/notifications/types"; + +export interface NotificationStateSnapshot extends NotificationState { + userId: number; +} + +type Listener = () => void; + +/** + * The unread count the bell shows, owned by nobody but this store. Three + * sources feed it - the session payload, realtime messages and explicit + * refreshes - and each carries the server's per-user revision. Whatever + * arrives, only a strictly newer revision replaces what is here, so a slow + * session response can never undo a realtime update that overtook it. + */ +export const createNotificationStateStore = () => { + let snapshot: NotificationStateSnapshot | null = null; + const listeners = new Set<Listener>(); + + const emit = () => listeners.forEach(listener => listener()); + + return { + /** Returns `true` when `next` was newer and replaced the snapshot. */ + 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, + /** Forget everything - on sign-out, or when another user signs in. */ + reset: () => { + if (snapshot === null) return; + snapshot = null; + emit(); + }, + subscribe: (listener: Listener) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + }; +}; + +export type NotificationStateStore = ReturnType< + typeof createNotificationStateStore +>; + +/** One per browser tab. Tabs stay in step through the shared WebSocket. */ +export const notificationStateStore = createNotificationStateStore(); + +/** The current user's unread state, or `null` before anything arrived. */ +export const useNotificationState = ( + userId: null | number | undefined, + store: NotificationStateStore = notificationStateStore, +): NotificationState | null => { + // The server never holds anyone's count, so hydration always starts from + // `null` - matching the HTML - and picks up the live value right after. + const snapshot = React.useSyncExternalStore( + store.subscribe, + store.get, + () => null, + ); + + return snapshot && 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..0b74c2661 --- /dev/null +++ b/packages/vitnode/src/views/notifications/notifications-actions.ts @@ -0,0 +1,135 @@ +import type { + NotificationState, + NotificationSubject, +} 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); +}; + +/** Marks one item read through the activity the user saw. */ +export const markNotificationReadInBrowser = async ({ + id, + throughSeq, +}: { + id: number; + throughSeq?: number; +}): Promise<NotificationState> => { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: { throughSeq }, params: { id } }, + method: "post", + module: "notifications", + options: { credentials: "include" }, + path: "/{id}/read", + }); + + return response.ok ? await response.json() : failed(response); +}; + +export const markNotificationUnreadInBrowser = async ({ + id, +}: { + id: number; +}): Promise<NotificationState> => { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { id } }, + method: "post", + module: "notifications", + options: { credentials: "include" }, + path: "/{id}/unread", + }); + + return response.ok ? await response.json() : failed(response); +}; + +export const archiveNotificationInBrowser = async ({ + id, +}: { + id: number; +}): Promise<NotificationState> => { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { id } }, + method: "post", + module: "notifications", + options: { credentials: "include" }, + path: "/{id}/archive", + }); + + return response.ok ? await response.json() : failed(response); +}; + +export const markAllNotificationsReadInBrowser = async ({ + category, +}: { + category?: string; +} = {}): Promise<NotificationState & { marked: number }> => { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: { category } }, + method: "post", + module: "notifications", + options: { credentials: "include" }, + path: "/read-all", + }); + + return response.ok ? await response.json() : failed(response); +}; + +export interface UpdateNotificationPreferencesBody { + digestHour?: number; + digestWeekday?: number; + timeZone?: null | string; + types?: Record< + string, + { email?: "daily" | "immediate" | "none" | "weekly"; inApp?: boolean } + >; +} + +export const updateNotificationPreferencesInBrowser = async ( + body: UpdateNotificationPreferencesBody, +): Promise<void> => { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body }, + method: "put", + module: "notifications", + options: { credentials: "include" }, + path: "/preferences", + }); + if (!response.ok) failed(response); +}; + +export type NotificationSubscriptionState = "following" | "muted" | "none"; + +export const setNotificationSubscriptionInBrowser = async ({ + state, + subject, +}: { + state: NotificationSubscriptionState; + subject: NotificationSubject; +}): Promise<NotificationSubscriptionState> => { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { + body: { + state, + subjectId: String(subject.id), + subjectType: subject.type, + }, + }, + method: "put", + module: "notifications", + options: { credentials: "include" }, + path: "/subscriptions", + }); + + return response.ok ? (await response.json()).state : 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..b21b6bf4e --- /dev/null +++ b/packages/vitnode/src/views/notifications/notifications-bell-content.tsx @@ -0,0 +1,182 @@ +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"; + +export const NOTIFICATIONS_HREF = "/notifications"; +export const NOTIFICATION_SETTINGS_HREF = "/settings/notifications"; + +/** `99+` past 99, so the badge never outgrows the icon. */ +export 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; +} + +/** + * The header bell. Its number comes from the session and realtime state, never + * from the list; the list is fetched only once the dropdown opens, and opening + * it never marks anything read. + */ +export const NotificationsBellContent = ({ + onMarkAllRead, + onOpenChange, + onOpenItem, + onRetry, + open, + recent, + unread, +}: NotificationsBellContentProps) => { + const t = useTranslations("core.global.notifications"); + const badge = formatUnreadBadge(unread); + + return ( + <Popover onOpenChange={onOpenChange} open={open}> + <PopoverTrigger + render={ + <Button + aria-label={ + unread > 0 + ? t("bell_label_unread", { count: unread }) + : t("bell_label") + } + className="relative" + size="icon" + variant="ghost" + /> + } + > + <BellIcon /> + {badge ? ( + <span + aria-hidden + className="bg-destructive absolute -end-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-xs leading-none font-semibold text-white tabular-nums" + data-testid="notifications-badge" + > + {badge} + </span> + ) : null} + </PopoverTrigger> + + <PopoverContent align="end" className="w-80 gap-2 p-2 sm:w-96"> + <div className="flex items-center justify-between gap-2 px-2 pt-1"> + <PopoverTitle className="text-base">{t("title")}</PopoverTitle> + <Button + disabled={unread === 0} + onClick={onMarkAllRead} + size="sm" + variant="ghost" + > + <CheckCheckIcon /> + {t("mark_all_read")} + </Button> + </div> + + <div aria-busy={recent.isPending} className="max-h-96 overflow-y-auto"> + {recent.isPending ? ( + <div className="flex flex-col gap-2 p-2" role="status"> + <span className="sr-only">{t("loading")}</span> + {Array.from({ length: 3 }, (_, index) => ( + <div className="flex items-start gap-3" key={index}> + <Skeleton className="size-9 rounded-full" /> + <div className="flex flex-1 flex-col gap-2"> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-3 w-1/3" /> + </div> + </div> + ))} + </div> + ) : recent.isError ? ( + <div + className="flex flex-col items-center gap-2 p-4 text-center" + role="alert" + > + <p className="text-muted-foreground text-sm">{t("load_error")}</p> + <Button onClick={onRetry} size="sm" variant="outline"> + {t("retry")} + </Button> + </div> + ) : recent.items && recent.items.length > 0 ? ( + <ul aria-label={t("recent")} className="flex flex-col gap-1"> + {recent.items.map(item => ( + <NotificationItem + item={item} + key={item.id} + onOpen={onOpenItem} + variant="compact" + /> + ))} + </ul> + ) : ( + <Empty className="p-6"> + <EmptyHeader> + <EmptyMedia variant="icon"> + <BellIcon /> + </EmptyMedia> + <EmptyTitle>{t("empty_title")}</EmptyTitle> + <EmptyDescription>{t("empty_desc")}</EmptyDescription> + </EmptyHeader> + </Empty> + )} + </div> + + <div className="flex items-center justify-between gap-2 border-t px-1 pt-2"> + <Button + nativeButton={false} + onClick={() => onOpenChange(false)} + render={<Link to={NOTIFICATIONS_HREF} />} + size="sm" + variant="ghost" + > + {t("view_all")} + </Button> + <Button + aria-label={t("settings")} + nativeButton={false} + onClick={() => onOpenChange(false)} + render={<Link to={NOTIFICATION_SETTINGS_HREF} />} + size="icon-sm" + title={t("settings")} + variant="ghost" + > + <Settings2Icon /> + </Button> + </div> + </PopoverContent> + </Popover> + ); +}; 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..1edb259db --- /dev/null +++ b/packages/vitnode/src/views/notifications/notifications-query.ts @@ -0,0 +1,270 @@ +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; + +import type { NotificationSubject } 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; +} + +/** Everything private to the signed-in user - dropped on sign-out. */ +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<NotificationsPage> => { + 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(); +}; + +export const NOTIFICATIONS_PAGE_SIZE = 20; +export const NOTIFICATIONS_RECENT_SIZE = 8; + +/** The full Notifications Center list. */ +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, + }); + +/** + * The few items the bell's dropdown shows. Only fetched when the dropdown + * opens - the bell's number comes from the session, never from this list. + */ +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 notificationStateQueryKey = (userId: number) => + [...NOTIFICATIONS_IDENTITY_ROOT, userId, "state"] as const; + +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; + description: null | string; + emailModes: ("daily" | "immediate" | "none" | "weekly")[]; + id: string; + label: string; + mandatory: boolean; + pluginId: string; + value: { email: "daily" | "immediate" | "none" | "weekly"; inApp: boolean }; +} + +export interface NotificationPreferencesView { + digestHour: number; + digestWeekday: number; + timeZone: null | string; + types: NotificationPreferenceTypeView[]; +} + +export const notificationPreferencesQueryKey = (userId: number) => + [...NOTIFICATIONS_IDENTITY_ROOT, userId, "preferences"] as const; + +export const notificationPreferencesQueryOptions = ({ + userId, +}: { + userId: number; +}) => + queryOptions({ + queryFn: async (): Promise<NotificationPreferencesView> => { + 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, + }); + +export interface NotificationSubscriptionView { + createdAt: Date | string; + label: null | string; + state: "following" | "muted"; + subjectId: string; + subjectType: string; +} + +export const notificationSubscriptionsQueryKey = (userId: number) => + [...NOTIFICATIONS_IDENTITY_ROOT, userId, "subscriptions"] as const; + +export const notificationSubscriptionsQueryOptions = ({ + userId, +}: { + userId: number; +}) => + queryOptions({ + queryFn: async (): Promise<NotificationSubscriptionView[]> => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: {} }, + method: "get", + module: "notifications", + path: "/subscriptions", + }); + if (!response.ok) throw new NotificationsRequestError(response.status); + + return (await response.json()).items; + }, + queryKey: notificationSubscriptionsQueryKey(userId), + retry: false, + staleTime: RECORD_STALE_TIME, + }); + +export const notificationSubscriptionQueryOptions = ({ + subject, + userId, +}: { + subject: NotificationSubject; + userId: number; +}) => + queryOptions({ + queryFn: async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { + params: { subjectId: String(subject.id), subjectType: subject.type }, + }, + method: "get", + module: "notifications", + path: "/subscriptions/{subjectType}/{subjectId}", + }); + if (!response.ok) throw new NotificationsRequestError(response.status); + + return (await response.json()).state; + }, + queryKey: [ + ...notificationSubscriptionsQueryKey(userId), + subject.type, + String(subject.id), + ] as const, + retry: false, + staleTime: RECORD_STALE_TIME, + }); diff --git a/packages/vitnode/src/ws/notifications.ts b/packages/vitnode/src/ws/notifications.ts index 49ad1b167..b06bb4f93 100644 --- a/packages/vitnode/src/ws/notifications.ts +++ b/packages/vitnode/src/ws/notifications.ts @@ -1,10 +1,15 @@ +import type { NotificationStateMessage } from "@/lib/notifications/types"; + import { CONFIG_PLUGIN } from "@/config"; import { createWebSocketChannel } from "./types"; export type VitNodeNotificationType = "error" | "info" | "success" | "warning"; -/** A notification pushed to a single user over the WebSocket. */ +/** + * A transient toast pushed to a single user over the WebSocket. Persistent + * notifications go through `c.get("notifications").publish` instead. + */ export interface VitNodeNotification { description?: string; title: string; @@ -19,3 +24,16 @@ export const notificationsChannel = createWebSocketChannel< module: "notifications", pluginId: CONFIG_PLUGIN.pluginId, }); + +/** + * The canonical unread state of the signed-in user. Every committed inbox + * change sends the absolute count with a per-user revision, never a +1/-1. + */ +export const notificationsStateChannel = createWebSocketChannel< + never, + NotificationStateMessage +>({ + id: "state", + module: "notifications", + pluginId: CONFIG_PLUGIN.pluginId, +}); diff --git a/plugins/blog/src/api/lib/notifications.ts b/plugins/blog/src/api/lib/notifications.ts new file mode 100644 index 000000000..27fee6829 --- /dev/null +++ b/plugins/blog/src/api/lib/notifications.ts @@ -0,0 +1,198 @@ +import type { EnvVitNode } from "@vitnode/core/api/middlewares/global.middleware"; +import type { Context } from "hono"; + +import { buildEventListener } from "@vitnode/core/api/lib/events"; +import { + buildNotificationSubject, + buildNotificationType, +} from "@vitnode/core/api/lib/notifications/registry"; +import { contentEventName } from "@vitnode/core/content"; +import { core_languages } from "@vitnode/core/database/languages"; +import { and, asc, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; + +import { blogPostContentType } from "@/content/post"; +import { + blog_categories, + blog_categories_translations, +} from "@/database/categories"; +import { + blog_posts_category_id, + blog_posts_translations, +} from "@/database/posts"; + +/** A blog category readers can follow to hear about new articles in it. */ +export const BLOG_CATEGORY_SUBJECT = "blog.category"; + +// Both content types are localized, so their translation tables always exist; +// the model types them as optional because a type without localization has none. +const requireTable = <T>(table: null | T, name: string): T => { + if (!table) throw new Error(`[Blog] The ${name} table is missing.`); + + return table; +}; +const postTranslations = requireTable( + blog_posts_translations, + "blog_posts_translations", +); +const categoryTranslations = requireTable( + blog_categories_translations, + "blog_categories_translations", +); + +const isPublished = async ( + c: Context<EnvVitNode>, + postId: number, +): Promise<boolean> => { + const [row] = await c + .get("db") + .select({ itemId: postTranslations.itemId }) + .from(postTranslations) + .where( + and( + eq(postTranslations.itemId, postId), + eq(postTranslations.status, "published"), + ), + ) + .limit(1); + + return !!row; +}; + +/** + * "New article in a category you follow". The audience is whoever follows any + * of the article's categories - core streams that list itself, so a category + * with ten thousand followers costs this listener one query. + */ +export const postPublishedNotification = buildNotificationType({ + id: "blog.post_published", + version: 1, + schema: z.object({ + postId: z.number().int().positive(), + slug: z.string().min(1).max(255), + title: z.string().min(1).max(255), + }), + category: "content", + label: "@vitnode/blog.notifications.post_published.label", + description: "@vitnode/blog.notifications.post_published.description", + subjectType: "blog.post", + defaults: { email: "daily", inApp: true }, + email: true, + // Every recipient shares one answer: an article is public once published. + // Unpublished or deleted since? Then nobody sees it - in the inbox it turns + // into the "no longer available" placeholder, and no email goes out. + access: async ({ c, data, userIds }) => + (await isPublished(c, data.postId)) ? userIds : [], + present: ({ data, t }) => ({ + body: t("@vitnode/blog.notifications.post_published.body"), + target: `/blog/${encodeURIComponent(data.slug)}`, + title: t("@vitnode/blog.notifications.post_published.title", { + title: data.title, + }), + }), +}); + +export const blogCategorySubject = buildNotificationSubject({ + type: BLOG_CATEGORY_SUBJECT, + canFollow: async ({ c, id }) => { + const categoryId = Number(id); + if (!Number.isSafeInteger(categoryId)) return false; + + const [row] = await c + .get("db") + .select({ id: blog_categories.id }) + .from(blog_categories) + .where(eq(blog_categories.id, categoryId)) + .limit(1); + + return !!row; + }, + resolveLabels: async ({ c, ids, locale }) => { + const categoryIds = ids.map(Number).filter(Number.isSafeInteger); + if (categoryIds.length === 0) return {}; + + const rows = await c + .get("db") + .select({ + code: core_languages.code, + id: categoryTranslations.itemId, + name: categoryTranslations.name, + }) + .from(categoryTranslations) + .innerJoin( + core_languages, + eq(core_languages.id, categoryTranslations.languageId), + ) + .where(inArray(categoryTranslations.itemId, categoryIds)); + + const labels: Record<string, string> = {}; + for (const row of rows) { + // The reader's language wins; any other translation is a fallback. + if (row.code === locale || !(String(row.id) in labels)) { + labels[String(row.id)] = row.name; + } + } + + return labels; + }, +}); + +/** The article's title and slug in its default language, falling back to any. */ +const loadPostSummary = async (c: Context<EnvVitNode>, postId: number) => { + const rows = await c + .get("db") + .select({ + code: core_languages.code, + slug: postTranslations.friendlyUrl, + title: postTranslations.title, + }) + .from(postTranslations) + .innerJoin( + core_languages, + eq(core_languages.id, postTranslations.languageId), + ) + .where(eq(postTranslations.itemId, postId)); + + return ( + rows.find( + row => row.code === blogPostContentType.localization.defaultLocale, + ) ?? rows[0] + ); +}; + +export const notifyCategoryFollowersListener = buildEventListener({ + event: contentEventName(blogPostContentType.id, "published"), + name: "notify-category-followers", + description: + "Tells followers of an article's categories that it was published", + handler: async (c, payload) => { + const postId = payload.contentId; + const [summary, categories] = await Promise.all([ + loadPostSummary(c, postId), + c + .get("db") + .select({ categoryId: blog_posts_category_id.relatedItemId }) + .from(blog_posts_category_id) + .where(eq(blog_posts_category_id.itemId, postId)) + .orderBy(asc(blog_posts_category_id.position)), + ]); + if (!summary || categories.length === 0) return; + + await c.get("notifications").publish({ + type: postPublishedNotification, + // Whoever pressed publish - or nobody, when a schedule fired it - is + // left out of their own announcement. + actorId: payload.scheduledBy ?? c.get("admin")?.user.id ?? null, + followersOf: categories.map(row => ({ + id: row.categoryId, + type: BLOG_CATEGORY_SUBJECT, + })), + subject: { id: postId, type: "blog.post" }, + data: { postId, slug: summary.slug, title: summary.title }, + // `publishedAt` is the first publication and never changes, so this + // announces an article once - not again after an unpublish and republish, + // and not twice when a scheduled publish is retried. + idempotencyKey: `post-published:${postId}`, + }); + }, +}); diff --git a/plugins/blog/src/api/modules/admin/admin.module.ts b/plugins/blog/src/api/modules/admin/admin.module.ts index 79b10572c..51bbd137c 100644 --- a/plugins/blog/src/api/modules/admin/admin.module.ts +++ b/plugins/blog/src/api/modules/admin/admin.module.ts @@ -2,6 +2,7 @@ import { buildModule } from "@vitnode/core/api/lib/module"; import { buildContentAdminModule } from "@vitnode/core/content/server"; import { blogLegacyEventListeners } from "@/api/lib/events"; +import { notifyCategoryFollowersListener } from "@/api/lib/notifications"; import { CONFIG_PLUGIN } from "@/const"; import { categoryContent } from "@/database/categories"; import { postContent } from "@/database/posts"; @@ -18,5 +19,5 @@ export const adminModule = buildModule({ ], // Event listeners are only collected from top-level modules, so the // compatibility adapters are registered here rather than on a nested one. - events: blogLegacyEventListeners, + events: [...blogLegacyEventListeners, notifyCategoryFollowersListener], }); diff --git a/plugins/blog/src/config.api.ts b/plugins/blog/src/config.api.ts index 5e5404c0e..7a4503434 100644 --- a/plugins/blog/src/config.api.ts +++ b/plugins/blog/src/config.api.ts @@ -3,14 +3,22 @@ import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin"; import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; import { buildContentPublicModule } from "@vitnode/core/content/server"; +import { + blogCategorySubject, + postPublishedNotification, +} from "@/api/lib/notifications"; import { adminModule } from "@/api/modules/admin/admin.module"; import { CONFIG_PLUGIN } from "@/const"; import { categoryContent } from "@/database/categories"; import { postContent } from "@/database/posts"; +import apiMessages from "@/locales/api"; export const blogApiPlugin = () => buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, + messages: apiMessages, + notificationTypes: [postPublishedNotification], + notificationSubjects: [blogCategorySubject], modules: [ adminModule, buildContentPublicModule({ diff --git a/plugins/blog/src/locales/api/en.json b/plugins/blog/src/locales/api/en.json new file mode 100644 index 000000000..06ce1b6e7 --- /dev/null +++ b/plugins/blog/src/locales/api/en.json @@ -0,0 +1,12 @@ +{ + "@vitnode/blog": { + "notifications": { + "post_published": { + "label": "New articles in categories you follow", + "description": "When an article is published in a blog category you follow.", + "title": "New article: {title}", + "body": "Fresh off the press in a category you follow." + } + } + } +} diff --git a/plugins/blog/src/locales/api/index.ts b/plugins/blog/src/locales/api/index.ts new file mode 100644 index 000000000..346cb9825 --- /dev/null +++ b/plugins/blog/src/locales/api/index.ts @@ -0,0 +1,8 @@ +import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types"; + +/** Strings the API renders - notifications and their emails. */ +const messages: LocaleMessagesMap = { + en: async () => await import("./en.json", { with: { type: "json" } }), +}; + +export default messages; diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index 917475a3d..fdd6c8a92 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -54,7 +54,8 @@ } }, "post": { - "published": "Published" + "published": "Published", + "follow_category": "Want to hear about new articles in this category?" } }, "@vitnode/blog:posts": "Articles", diff --git a/plugins/blog/src/pages/post-page.tsx b/plugins/blog/src/pages/post-page.tsx index fc4c1ac8c..43f749823 100644 --- a/plugins/blog/src/pages/post-page.tsx +++ b/plugins/blog/src/pages/post-page.tsx @@ -10,12 +10,14 @@ import { contentDeliveryPageHead, } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { FollowSubjectButton } from "@vitnode/core/tanstack/notifications"; import { useTranslations } from "use-intl"; import { z } from "zod"; import { CONFIG_PLUGIN } from "@/const"; const zodBlogPost = z.object({ + categoryId: z.array(z.number()).catch([]), content: z.string(), coverImage: zodContentFileDescriptor.nullable(), coverImageAlt: z.string().nullable(), @@ -100,6 +102,17 @@ const BlogPostPage = ({ <h1 className="text-foreground text-3xl font-semibold tracking-tight text-balance md:text-4xl"> {item.title} </h1> + + {item.categoryId[0] === undefined ? null : ( + <div className="flex flex-wrap items-center gap-3"> + <p className="text-muted-foreground text-sm leading-relaxed"> + {t("follow_category")} + </p> + <FollowSubjectButton + subject={{ id: item.categoryId[0], type: "blog.category" }} + /> + </div> + )} </header> {item.coverImage ? ( diff --git a/turbo.json b/turbo.json index 9a0f82827..dbb0d2a0e 100644 --- a/turbo.json +++ b/turbo.json @@ -4,7 +4,8 @@ "tasks": { "test": { "dependsOn": ["^test"], - "cache": false + "cache": false, + "passThroughEnv": ["VITNODE_TEST_POSTGRES_URL"] }, "test:types": { "dependsOn": ["^test:types"], From 9e8e973977a798fadf0387dca329155facf21660 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axen@vitnode.com> Date: Sun, 4 Oct 2026 14:36:01 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(notifications):=20=E2=9C=A8=20add=20de?= =?UTF-8?q?lete=20all=20notifications=20dialog=20and=20reset=20members=20f?= =?UTF-8?q?unctionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implemented `DeleteAllDialog` for confirming deletion of all notifications. - Added `ResetMembersButton` to reset member preferences with confirmation. - Created `NotificationsSettingsSheet` and `NotificationsSettingsSheetBody` for managing notification settings. - Introduced type policies for notifications with tests for various scenarios. --- .../migration.sql | 5 + .../snapshot.json | 11491 ++++++++++++++++ .../docs/dev/events/built-in-events.mdx | 21 + .../docs/dev/notifications/admincp.mdx | 195 +- .../dev/notifications/email-and-digests.mdx | 11 +- .../docs/dev/notifications/preferences.mdx | 9 +- .../dev/notifications/queue-and-scale.mdx | 49 +- apps/web/src/locales/@vitnode/core/pl.json | 5 +- packages/vitnode/src/api/config.ts | 1 + .../api/lib/notifications/preferences.test.ts | 21 + .../src/api/lib/notifications/preferences.ts | 84 +- .../src/api/middlewares/global.middleware.ts | 7 + packages/vitnode/src/api/models/events.ts | 23 + .../src/api/models/notifications/admin.ts | 160 +- .../notifications/danger.integration.test.ts | 266 + .../src/api/models/notifications/danger.ts | 230 + .../models/notifications/email-cap.test.ts | 49 + .../src/api/models/notifications/email-cap.ts | 46 + .../api/models/notifications/email-queue.ts | 19 +- .../src/api/models/notifications/email.ts | 92 +- .../fanout.load.integration.test.ts | 6 +- .../src/api/models/notifications/fanout.ts | 5 +- .../api/models/notifications/maintenance.ts | 10 +- .../api/models/notifications/preferences.ts | 39 +- .../src/api/models/notifications/shared.ts | 40 +- .../api/models/notifications/stats.test.ts | 82 + .../src/api/models/notifications/stats.ts | 236 + .../notifications.admin.module.ts | 16 + .../notifications/routes/danger.route.test.ts | 126 + .../notifications/routes/danger.route.ts | 111 + .../notifications/routes/overview.route.ts | 27 +- .../notifications/routes/settings.route.ts | 14 +- .../admin/notifications/routes/stats.route.ts | 61 + .../notifications/routes/preferences.route.ts | 2 + .../vitnode/src/database/notifications.ts | 4 +- packages/vitnode/src/locales/en.json | 352 +- .../src/tanstack/admin/notifications/index.ts | 5 +- .../src/tanstack/admin/notifications/query.ts | 10 +- .../tanstack/admin/notifications/route.tsx | 10 +- .../tanstack/admin/notifications/screen.tsx | 13 +- .../notifications/settings-screen.tsx | 34 +- packages/vitnode/src/tests/notifications.ts | 22 + .../system/notifications/activity-content.tsx | 349 + .../notifications/delete-all-dialog.tsx | 124 + .../notifications/deliveries-content.tsx | 328 - .../notifications/delivery-status-badge.tsx | 59 - .../system/notifications/health-content.tsx | 205 - .../core/system/notifications/health.test.ts | 116 - .../views/core/system/notifications/health.ts | 94 - .../notifications/maintenance-content.tsx | 216 - .../notifications/notifications-content.tsx | 53 +- .../notifications/notifications-mutations.ts | 120 +- .../notifications/notifications-query.ts | 120 +- .../system/notifications/reset-members.tsx | 53 + .../system/notifications/settings-form.tsx | 189 - .../notifications/settings-sheet-content.tsx | 736 + .../system/notifications/settings-sheet.tsx | 68 + .../core/system/notifications/statuses.ts | 18 - .../system/notifications/type-policy.test.ts | 116 + .../core/system/notifications/type-policy.ts | 93 + .../system/notifications/types-content.tsx | 568 +- .../notifications/notifications-query.ts | 2 + packages/vitnode/src/vitnode.config.ts | 2 + 63 files changed, 15738 insertions(+), 1900 deletions(-) create mode 100644 apps/api/migrations/20261004112416_allow_null_notification_digest_schedule/migration.sql create mode 100644 apps/api/migrations/20261004112416_allow_null_notification_digest_schedule/snapshot.json create mode 100644 packages/vitnode/src/api/models/notifications/danger.integration.test.ts create mode 100644 packages/vitnode/src/api/models/notifications/danger.ts create mode 100644 packages/vitnode/src/api/models/notifications/email-cap.test.ts create mode 100644 packages/vitnode/src/api/models/notifications/email-cap.ts create mode 100644 packages/vitnode/src/api/models/notifications/stats.test.ts create mode 100644 packages/vitnode/src/api/models/notifications/stats.ts create mode 100644 packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.test.ts create mode 100644 packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/notifications/routes/stats.route.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/activity-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/delete-all-dialog.tsx delete mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/deliveries-content.tsx delete mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/delivery-status-badge.tsx delete mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/health-content.tsx delete mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/health.test.ts delete mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/health.ts delete mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/maintenance-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/reset-members.tsx delete mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/settings-form.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/settings-sheet-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/settings-sheet.tsx delete mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/statuses.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/type-policy.test.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/notifications/type-policy.ts 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/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index 16a8865d5..2cb8a81df 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -402,6 +402,27 @@ or the bottom bar, never both. </Accordion> </Accordions> +## Notification events + +Eight names, declared in `VitNodeEvents` in +`packages/vitnode/src/api/models/events.ts`. Each fires **after** an +administrator's change in [AdminCP → Notifications](/docs/dev/notifications/admincp) +has been saved. The envelope's `actor` is that administrator. + +| Event | Payload | Fires when | +| ---------------------------------- | ------------------------ | -------------------------------------------------------------------------------- | +| `notifications.settings.updated` | `{ keys }` | Installation settings are saved - `keys` lists the settings that were sent | +| `notifications.type.updated` | `{ typeId }` | What a type does by default (list, push, email, member can edit) changes | +| `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. + ## Page layout events One name, declared in `VitNodeEvents` in diff --git a/apps/web/content/docs/dev/notifications/admincp.mdx b/apps/web/content/docs/dev/notifications/admincp.mdx index 5d47d8598..6ac590890 100644 --- a/apps/web/content/docs/dev/notifications/admincp.mdx +++ b/apps/web/content/docs/dev/notifications/admincp.mdx @@ -1,121 +1,168 @@ --- title: Notifications in the AdminCP -description: Monitor and manage VitNode notifications in the AdminCP - delivery health, installation settings, per-type policy, email deliveries with retry, test email, unread count repair and cleanup. +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 whether notifications are flowing, decide which types run on your site, -and fix things when they drift. +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. Top row of health cards: "Notification email: Ready", "Background worker: Running", "Events" with small status counts, "Email deliveries" with sent/failed counts, "Oldest pending event: Nothing waiting". Below, an "Installation settings" card with a "Send notification emails" switch and number inputs, then a "Notification types" list with In-app/Email controls per type. */} +{/* 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: health, settings, types and deliveries, read-only | -| `notifications.can_edit` | Change installation settings and per-type policy | -| `notifications.can_manage` | Retry deliveries, send a test email, check and fix unread counts, run cleanup | +| 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). -## Health +## Activity -The health cards answer "is anything stuck?": +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: -- **Notification email** - ready, turned off, or no adapter configured. -- **Background worker** - running, stale or not configured. Nothing is - delivered without a working [cron adapter](/docs/dev/cron). -- **Events** - how many are pending, processing, completed or failed. -- **Email deliveries** - counts per status. -- **Oldest pending event** - the longer this gets, the later people hear about - things. +- **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. -A warning banner lists failed deliveries, failed events, failed notification -queue tasks and a missing or stale cron. +Days and hours follow the site's time zone, so "today" means the same thing to +everyone on the screen. -## Installation settings +If notifications are [paused](#danger-zone), a banner at the top says so. -| Setting | Default | Range | Effect | -| ------------------------ | ------- | ------- | --------------------------------------------------------------------------------------- | -| Send notification emails | On | - | Off keeps every notification in-app, whatever users picked. Account email is unaffected | -| Keep notifications for | 90 days | 1-3650 | Inbox items older than this are removed by the nightly cleanup | -| Fan-out batch size | 500 | 10-5000 | Recipients handled per fan-out transaction | -| Email batch size | 50 | 1-500 | Deliveries picked up per email run | -| Email concurrency | 4 | 1-20 | Emails sent in parallel during a run | +## Notification types -Changes apply from the next batch. +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: -## Per-type policy +| 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 | -Every registered type is listed with its plugin, category, version and whether -it is grouped or mandatory. For each type you can set: +- **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. -- **Enabled** - off stops the type for everyone. Mandatory types are always on. -- **Allow email** - off removes the email channel for this type. -- **In-app by default** and **Default email** - the installation default for - users who never chose. - -Users' own choices still win over the defaults. See +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). -## Deliveries +<Callout title="Push is a placeholder for now"> + VitNode doesn't deliver push notifications yet. The **Push** choice is saved + with the type so it's ready when push lands. +</Callout> + +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 - +muted types, email choices and their digest schedule - so everyone follows the +defaults on this page again. The confirmation says how many members picked +their own settings. It can't be undone. + +Follows and muted subjects (like a muted blog category) are kept. -A list of email delivery records, newest first, filterable by status. Each row -shows the type, mode (immediate, daily, weekly or test), attempts, item count, -a sanitized error and the recipient's **user id**. +## Settings -**Retry** puts a failed delivery back in line with fresh attempts. It keeps the -same row and idempotency key, so the provider sees the same key and the same -notifications are rendered. See -[Email and digests](/docs/dev/notifications/email-and-digests#delivery-records). +The **Settings** button opens a sheet. Every row shows its current value; tap +it to change that one value and save. Switches save right away. -## Maintenance +| Setting | Default | Range | Effect | +| -------------------------- | ------- | ------- | -------------------------------------------------------------------------------------------- | +| Send notification emails | On | - | Off keeps every notification in the list, whatever members picked. Account email is unaffected | +| Emails per member per hour | No limit | 0-500 | Immediate emails over the limit wait until the hour is up. Digests are never held back | +| Daily digest time | 08:00 | 0-23 | For members who haven't picked their own, in their time zone | +| Weekly digest day | Monday | - | For members who haven't picked their own | -- **Send test email to me** queues a sample notification email to the signed-in - administrator's own address. It cannot be sent to anyone else. -- **Check unread counts** compares every stored unread count with the actual - inbox and reports how many people differ. It changes nothing. -- **Fix unread counts** recalculates every mismatched count from the inbox and - pushes the corrected badge to those users right away. -- **Run cleanup now** queues the retention cleanup instead of waiting for 03:17. +Retention and worker batch sizes are deployment settings, set in your API +config rather than here - see +[Queue and scale → Configuration](/docs/dev/notifications/queue-and-scale#configuration). +The sheet shows the current retention period at the bottom. -## What diagnostics hide +### Tools -The screen is built for finding faults without reading anyone's mail: +- **Send a test email to me** queues a sample notification email to your own + address. It can't be sent to anyone else. +- **Run cleanup now** removes notifications older than the configured + retention period now instead of waiting for 03:17. -- recipients show as user ids - never email addresses; -- no notification titles, bodies or email subjects are shown; -- provider errors are stored with email addresses, tokens and keys redacted. +### 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` | `/deliveries` | `can_view` | -| `PUT` | `/settings` | `can_edit` | -| `PUT` | `/types/{type}` | `can_edit` | -| `POST` | `/deliveries/{id}/retry` | `can_manage` | -| `POST` | `/test-email` | `can_manage` | -| `POST` | `/reconcile` | `can_manage` | -| `POST` | `/cleanup` | `can_manage` | - -`POST /reconcile` takes `{ "dryRun": true }` to check only, and an optional -`userId` to look at one user. +| Method | Path | Permission | +| ------ | ---------------------------- | ------------ | +| `GET` | `/overview` | `can_view` | +| `GET` | `/stats` | `can_view` | +| `GET` | `/deliveries` | `can_view` | +| `PUT` | `/settings` | `can_edit` | +| `PUT` | `/types/{type}` | `can_edit` | +| `POST` | `/test-email` | `can_manage` | +| `POST` | `/cleanup` | `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 <Cards> + <Card title="Preferences" href="/docs/dev/notifications/preferences" /> <Card - title="Queue and scale" - href="/docs/dev/notifications/queue-and-scale" + title="Email and digests" + href="/docs/dev/notifications/email-and-digests" /> <Card title="Troubleshooting" diff --git a/apps/web/content/docs/dev/notifications/email-and-digests.mdx b/apps/web/content/docs/dev/notifications/email-and-digests.mdx index 9338ab63a..4e99db882 100644 --- a/apps/web/content/docs/dev/notifications/email-and-digests.mdx +++ b/apps/web/content/docs/dev/notifications/email-and-digests.mdx @@ -19,6 +19,13 @@ 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`. +### Hourly limit + +**Emails per member per hour** in the AdminCP caps immediate emails. Ones over +the limit aren't dropped: they wait until the oldest email of that hour is an +hour old, then go out. Digests are never held back. `0` (the default) means no +limit. + ## Daily and weekly digests Events for digest users wait as pending email receipts. Every 5 minutes the @@ -26,7 +33,9 @@ Events for digest users wait as pending email receipts. Every 5 minutes the - **Periods are local.** A daily digest covers one calendar day in the user's time zone and goes out at their `digestHour`. A weekly one covers seven days - ending on their `digestWeekday`. Defaults: 08:00, Monday. + ending on their `digestWeekday`. Members who never picked follow the + installation default from the [AdminCP](/docs/dev/notifications/admincp#settings) - + 08:00, Monday out of the box. - **Time zone fallback.** The user's own time zone, then the time zone of their language, then UTC. - **DST-safe.** Periods are contiguous. On the night clocks change, a "day" is diff --git a/apps/web/content/docs/dev/notifications/preferences.mdx b/apps/web/content/docs/dev/notifications/preferences.mdx index 8831787ff..c76520e61 100644 --- a/apps/web/content/docs/dev/notifications/preferences.mdx +++ b/apps/web/content/docs/dev/notifications/preferences.mdx @@ -19,11 +19,18 @@ One function decides what a user receives, in this order: this. 2. **Mandatory type** → always in-app. Email follows the installation default. 3. **The user muted the subject** → nothing. -4. **The user's own choice** for the type. +4. **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)). 5. **The installation default** for the type, set in the [AdminCP](/docs/dev/notifications/admincp). 6. **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`. + In-app and email are resolved separately. A user can take a type by email only, in-app only, both or neither. diff --git a/apps/web/content/docs/dev/notifications/queue-and-scale.mdx b/apps/web/content/docs/dev/notifications/queue-and-scale.mdx index 23dff2551..99984f8e2 100644 --- a/apps/web/content/docs/dev/notifications/queue-and-scale.mdx +++ b/apps/web/content/docs/dev/notifications/queue-and-scale.mdx @@ -32,7 +32,7 @@ in the background - see [Troubleshooting](/docs/dev/notifications/migration-and- ## Batched fan-out The fan-out task works through candidates in batches of `fanoutBatchSize` -(default 500, configurable from 10 to 5,000 in the AdminCP). Named +(default 500, from 10 to 5,000 - see [Configuration](#configuration)). Named `recipients` come first, then each `followersOf` subject's followers, streamed in user id order. @@ -109,8 +109,8 @@ The migration adds the indexes the hot paths need: `notifications-cleanup` removes, in batches: -- inbox items whose last activity is older than `retentionDays` (default 90), - taking unread ones off their owner's count; +- 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 @@ -118,6 +118,49 @@ The migration adds the indexes the hot paths need: 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. + +<Callout title="When to change them"> + 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. +</Callout> + +The AdminCP shows the retention period in the +[Settings sheet](/docs/dev/notifications/admincp#settings) but can't change it. + ## Related <Cards> diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index aaa5d2a9d..3f47d0d03 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -22,7 +22,7 @@ "@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": "Ponawianie wysyłek, testowe e-maile i konserwacja", + "@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", @@ -1195,7 +1195,8 @@ "unfollowed": "Nie obserwujesz", "unmuted": "Wyciszenie wyłączone", "error": "Nie udało się tego zmienić. Spróbuj ponownie." - } + }, + "locked": "Ustawione przez administratora" } }, "sign_in": { 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/notifications/preferences.test.ts b/packages/vitnode/src/api/lib/notifications/preferences.test.ts index 873135bbc..f6be4c093 100644 --- a/packages/vitnode/src/api/lib/notifications/preferences.test.ts +++ b/packages/vitnode/src/api/lib/notifications/preferences.test.ts @@ -85,4 +85,25 @@ describe("resolveNotificationChannels", () => { inApp: false, }); }); + + 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 }); + }); + + 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 }); + expect( + resolve({ definition: mandatory, policy: { allowInApp: false } }).inApp, + ).toBe(true); + }); }); diff --git a/packages/vitnode/src/api/lib/notifications/preferences.ts b/packages/vitnode/src/api/lib/notifications/preferences.ts index c7972d18c..91fb2a0e9 100644 --- a/packages/vitnode/src/api/lib/notifications/preferences.ts +++ b/packages/vitnode/src/api/lib/notifications/preferences.ts @@ -7,33 +7,73 @@ import type { AnyNotificationTypeDefinition } from "./registry"; export interface NotificationTypePolicy { /** `false` keeps the type off email whatever users choose. */ allowEmail?: boolean; + allowInApp?: boolean; + allowPush?: boolean; /** Default email mode for users who never chose one. */ email?: NotificationEmailMode; /** `false` stops the type entirely (mandatory types ignore it). */ enabled?: boolean; /** Default in-app setting for users who never chose one. */ inApp?: boolean; + memberCanEdit?: boolean; } export interface NotificationGlobalSettings { + digestHour: number; + digestWeekday: number; + emailCapPerHour: number; + /** Master switch for notification email - account email is unaffected. */ + emailEnabled: boolean; + paused: boolean; +} + +export const DEFAULT_NOTIFICATION_SETTINGS: NotificationGlobalSettings = { + digestHour: 8, + digestWeekday: 1, + emailCapPerHour: 0, + emailEnabled: true, + paused: false, +}; + +export interface NotificationWorkerSettings { /** Upper bound on emails one drain run sends. */ emailBatchSize: number; /** Emails sent in parallel within one drain run. */ emailConcurrency: number; - /** Master switch for notification email - account email is unaffected. */ - emailEnabled: boolean; /** Recipients handled per fan-out transaction. */ fanoutBatchSize: number; /** Inbox items older than this many days are removed by cleanup. */ retentionDays: number; } -export const DEFAULT_NOTIFICATION_SETTINGS: NotificationGlobalSettings = { - emailBatchSize: 50, - emailConcurrency: 4, - emailEnabled: true, - fanoutBatchSize: 500, - retentionDays: 90, +export 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> = {}, +): 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 { @@ -44,6 +84,22 @@ export interface NotificationChannels { const OFF: NotificationChannels = { email: "none", inApp: false }; /** Whether this type can email at all on this installation. */ +export const isNotificationInAppAvailable = ({ + definition, + policy, +}: { + definition: AnyNotificationTypeDefinition; + policy?: NotificationTypePolicy; +}): boolean => !!definition.mandatory || policy?.allowInApp !== false; + +export const isNotificationLockedForMembers = ({ + definition, + policy, +}: { + definition: AnyNotificationTypeDefinition; + policy?: NotificationTypePolicy; +}): boolean => !!definition.mandatory || policy?.memberCanEdit === false; + export const isNotificationEmailAvailable = ({ definition, emailConfigured, @@ -62,7 +118,7 @@ export const isNotificationEmailAvailable = ({ * 1. Installation policy disables the type → nothing (mandatory types excepted). * 2. Mandatory types → always in-app; email follows the installation default. * 3. The user muted the subject → nothing. - * 4. The user's own choice for the type. + * 4. The user's own choice for the type, unless the type is locked for members. * 5. The installation default for the type. * 6. The type's own default. * @@ -97,8 +153,14 @@ export const resolveNotificationChannels = ({ if (muted) return OFF; + const choice = isNotificationLockedForMembers({ definition, policy }) + ? undefined + : preference; + return { - email: emailAvailable ? (preference?.email ?? defaultEmail) : "none", - inApp: preference?.inApp ?? policy?.inApp ?? definition.defaults.inApp, + email: emailAvailable ? (choice?.email ?? defaultEmail) : "none", + inApp: + isNotificationInAppAvailable({ definition, policy }) && + (choice?.inApp ?? policy?.inApp ?? definition.defaults.inApp), }; }; diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index ad9dc2a36..6e07e31ae 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -43,6 +43,7 @@ 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"; @@ -67,6 +68,7 @@ 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"; @@ -154,6 +156,7 @@ export interface EnvVariablesVitNode { navigation: NavigationPreset[]; /** Every notification type and subject the installed plugins register. */ notifications?: NotificationRegistry; + notificationWorkers?: NotificationWorkerSettings; permissionStaff: PermissionStaffCatalogEntry[]; /** Which personal-information fields this install offers. */ personalInformationFields: PersonalInformationFields; @@ -212,6 +215,7 @@ export const globalMiddleware = ({ events, plugins, i18n, + notifications, search, storage, users, @@ -227,6 +231,7 @@ export const globalMiddleware = ({ | "email" | "events" | "i18n" + | "notifications" | "plugins" | "search" | "storage" @@ -235,6 +240,7 @@ export const globalMiddleware = ({ Pick<VitNodeConfig, "metadata"> & { cacheClient: CacheClient | null; }) => { + const notificationWorkers = resolveNotificationWorkerSettings(notifications); const pluginsMetadata = plugins.map(plugin => ({ id: plugin.pluginId, })); @@ -477,6 +483,7 @@ export const globalMiddleware = ({ permissionStaff: permissionStaffMetadata, navigation: navigationMetadata, notifications: notificationsMetadata, + notificationWorkers, contentModels: contentModelsMetadata, contentRevalidateOrigins: content?.revalidateOrigins, contentTypes: contentTypesMetadata, diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 14dc8d868..83e21ed83 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -25,6 +25,29 @@ export interface VitNodeEvents { "navigation.updated": { navigationId: number; }; + "notifications.deleted_all": { + items: number; + }; + "notifications.emails_cancelled": { + count: number; + }; + "notifications.paused": Record<string, never>; + "notifications.preferences_reset": { + members: number; + }; + "notifications.read_all": { + items: number; + members: number; + }; + "notifications.resumed": { + requeuedEvents: number; + }; + "notifications.settings.updated": { + keys: string[]; + }; + "notifications.type.updated": { + typeId: string; + }; "role.created": { roleId: number; }; diff --git a/packages/vitnode/src/api/models/notifications/admin.ts b/packages/vitnode/src/api/models/notifications/admin.ts index 98541ce84..65261fdb3 100644 --- a/packages/vitnode/src/api/models/notifications/admin.ts +++ b/packages/vitnode/src/api/models/notifications/admin.ts @@ -6,7 +6,11 @@ import type { NotificationTypePolicy, } from "@/api/lib/notifications/preferences"; -import { isNotificationEmailAvailable } from "@/api/lib/notifications/preferences"; +import { + isNotificationEmailAvailable, + isNotificationInAppAvailable, + isNotificationLockedForMembers, +} from "@/api/lib/notifications/preferences"; import { core_cron } from "@/database/cron"; import { core_notification_deliveries, @@ -22,8 +26,11 @@ import type { NotificationsContext } from "./shared"; import { dispatchEmailDrain } from "./email-queue"; import { reconcileNotificationCounts } from "./inbox"; +import { translateOr } from "./preferences"; +import { createTranslatorCache } from "./render"; import { getNotificationRegistry, + getNotificationWorkers, GLOBAL_SETTINGS_KEY, isEmailConfigured, loadNotificationSettings, @@ -47,48 +54,77 @@ export const getNotificationsOverview = async (c: NotificationsContext) => { const core = c.get("core"); const settings = await loadNotificationSettings(db); const emailConfigured = isEmailConfigured(c, settings); + const { t } = await createTranslatorCache(c)(c.get("admin")?.user.language); - const [queueRows, deliveryRows, eventRows, [oldestPending], [cron]] = - await Promise.all([ - db - .select({ - count: count(), - key: sql<string>`${core_queue.name} || ':' || ${core_queue.status}`, - }) - .from(core_queue) - .where( - and( - eq(core_queue.pluginId, NOTIFICATIONS_PLUGIN_ID), - like(core_queue.name, "notifications-%"), + const [ + queueRows, + deliveryRows, + eventRows, + [oldestPending], + [cron], + [inbox], + [members], + ] = await Promise.all([ + db + .select({ + count: count(), + key: sql<string>`${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), + db + .select({ + lastActivity: sql< + null | string + >`max(coalesce(${core_cron.lastRun}, ${core_cron.createdAt}))`, + }) + .from(core_cron), + db + .select({ + items: count(), + unread: + sql<number>`count(*) filter (where ${core_notifications.archivedAt} IS NULL AND ${core_notifications.readSeq} < ${core_notifications.activitySeq})`.mapWith( + Number, ), - ) - .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), - db - .select({ - lastActivity: sql< - null | string - >`max(coalesce(${core_cron.lastRun}, ${core_cron.createdAt}))`, - }) - .from(core_cron), - ]); + }) + .from(core_notifications), + db + .select({ customized: count() }) + .from(core_notification_user_state) + .where( + sql`${core_notification_user_state.preferences} <> '{}'::jsonb OR ${core_notification_user_state.digestHour} IS NOT NULL OR ${core_notification_user_state.digestWeekday} IS NOT NULL`, + ), + ]); return { + counts: { + customizedMembers: members?.customized ?? 0, + inboxItems: inbox?.items ?? 0, + queuedEmails: countBy(deliveryRows).pending ?? 0, + unreadItems: inbox?.unread ?? 0, + }, email: { adapterConfigured: !!core.email?.adapter, enabled: settings.global.emailEnabled, @@ -104,6 +140,7 @@ export const getNotificationsOverview = async (c: NotificationsContext) => { queue: countBy(queueRows), }, settings: settings.global, + workers: getNotificationWorkers(c), types: getNotificationRegistry(c).list.map(({ definition, pluginId }) => { const policy = settings.policies.get(definition.id) ?? {}; @@ -118,14 +155,20 @@ export const getNotificationsOverview = async (c: NotificationsContext) => { emailSupported: !!definition.email, grouped: !!definition.grouping, id: definition.id, - label: definition.label, + 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, }; @@ -155,31 +198,46 @@ export const updateNotificationGlobalSettings = async ( const { global } = await loadNotificationSettings(c.get("db")); const next = normalizeGlobalSettings({ ...global, ...patch }); await upsertSetting(c, GLOBAL_SETTINGS_KEY, { ...next }); + await c.get("events").emit("notifications.settings.updated", { + keys: Object.keys(patch), + }); return next; }; +const POLICY_KEYS = [ + "allowEmail", + "allowInApp", + "allowPush", + "email", + "enabled", + "inApp", + "memberCanEdit", +] as const satisfies readonly (keyof NotificationTypePolicy)[]; + export const updateNotificationTypePolicy = async ( c: NotificationsContext, typeId: string, - policy: NotificationTypePolicy, -): Promise<void> => { + patch: NotificationTypePolicy, +): Promise<NotificationTypePolicy> => { const registered = getNotificationRegistry(c).get(typeId); if (!registered) throw new HTTPException(404); - if (policy.email && policy.email !== "none" && !registered.definition.email) { + if (patch.email && patch.email !== "none" && !registered.definition.email) { throw new HTTPException(400, { message: "This type has no email channel.", }); } - await upsertSetting(c, typeSettingsKey(typeId), { - ...(policy.allowEmail === undefined - ? {} - : { allowEmail: policy.allowEmail }), - ...(policy.email === undefined ? {} : { email: policy.email }), - ...(policy.enabled === undefined ? {} : { enabled: policy.enabled }), - ...(policy.inApp === undefined ? {} : { inApp: policy.inApp }), - }); + 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 upsertSetting(c, typeSettingsKey(typeId), { ...merged }); + await c.get("events").emit("notifications.type.updated", { typeId }); + + return merged; }; /** 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..e83d21c29 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/danger.integration.test.ts @@ -0,0 +1,266 @@ +// @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 { + updateNotificationGlobalSettings, + updateNotificationTypePolicy, +} from "./admin"; +import { + cancelQueuedNotificationEmails, + deleteAllNotifications, + markEverythingRead, + pauseNotifications, + resetMemberNotificationPreferences, + resumeNotifications, +} from "./danger"; +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("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("holds immediate emails over the hourly cap, then cancels what is still queued", async () => { + const [user] = await h.createUsers(1); + await updateNotificationGlobalSettings(h.c, { emailCapPerHour: 1 }); + await publish(alertType, user); + await publish(alertType, user); + await publish(newsType, user); + await settle(); + + const deliveries = await deliveriesOf(user); + expect(deliveries.filter(row => row.status === "sent")).toHaveLength(1); + const held = deliveries.find(row => row.status === "pending"); + expect(held?.availableAt.getTime()).toBeGreaterThan( + Date.now() + 50 * 60 * 1000, + ); + expect(held?.attempts).toBe(0); + + const [other] = await h.createUsers(1); + await publish(alertType, other); + await settle(); + expect((await deliveriesOf(other)).map(row => row.status)).toEqual([ + "sent", + ]); + + const { cancelled } = await cancelQueuedNotificationEmails(h.c); + expect(cancelled).toBeGreaterThanOrEqual(1); + + const after = await deliveriesOf(user); + expect(after.find(row => row.id === held?.id)).toMatchObject({ + skipReason: "cancelled", + status: "skipped", + }); + const pendingReceipts = await h.c + .get("db") + .select() + .from(core_notification_receipts) + .where(eq(core_notification_receipts.userId, user)); + expect(pendingReceipts.every(row => !row.emailPending)).toBe(true); + + await updateNotificationGlobalSettings(h.c, { emailCapPerHour: 0 }); + }); + + 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 stats = await getNotificationStats(h.c, { + range: "24h", + timeZone: "Europe/Warsaw", + }); + + expect(stats.points).toHaveLength(24); + expect(stats.totals.events).toBeGreaterThanOrEqual(7); + expect(stats.totals.sent).toBeGreaterThanOrEqual(2); + }); + + it("resets every member's own choices to the installation defaults", async () => { + const [user] = await h.createUsers(1); + await updateNotificationPreferences(h.c, user, { + digestHour: 20, + 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({ + digestHour: null, + digestWeekday: null, + 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..745d541a0 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/danger.ts @@ -0,0 +1,230 @@ +import { and, eq, inArray, isNull, notExists, sql } from "drizzle-orm"; + +import { + core_notification_deliveries, + core_notification_events, + core_notification_receipts, + core_notification_settings, + core_notification_user_state, + core_notifications, +} from "@/database/notifications"; + +import type { NotificationsContext } from "./shared"; + +import { dispatchEmailDrain } from "./email-queue"; +import { applyUnreadDeltas, lockUserStates } from "./fanout"; +import { removeNotificationItems, UNREAD_SQL } from "./inbox"; +import { + GLOBAL_SETTINGS_KEY, + loadNotificationSettings, + NOTIFICATIONS_PLUGIN_ID, + QUEUE_NOTIFICATIONS_FANOUT, + sendNotificationStates, +} from "./shared"; + +const USERS_PER_BATCH = 200; +const DELETE_BATCH = 1000; + +const setPaused = async (c: NotificationsContext, paused: boolean) => { + const { global } = await loadNotificationSettings(c.get("db")); + const value = { ...global, paused }; + await c + .get("db") + .insert(core_notification_settings) + .values({ key: GLOBAL_SETTINGS_KEY, updatedAt: new Date(), value }) + .onConflictDoUpdate({ + set: { updatedAt: new Date(), value }, + target: core_notification_settings.key, + }); +}; + +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"])); + + for (const { id } of waiting) { + await 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), + rows.length === 0 + ? isNull(core_notification_receipts.emailDeliveryId) + : sql`(${core_notification_receipts.emailDeliveryId} IS NULL OR ${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 result = 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<number, number>(); + 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, result.updates); + items += result.count; + members += result.updates.length; + if (result.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 db = c.get("db"); + const items = await removeNotificationItems(c, { where: sql`true` }); + + 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( + 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; + } + + 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({ + digestHour: null, + digestWeekday: null, + preferences: sql`'{}'::jsonb`, + updatedAt: new Date(), + }) + .where( + sql`${core_notification_user_state.preferences} <> '{}'::jsonb OR ${core_notification_user_state.digestHour} IS NOT NULL OR ${core_notification_user_state.digestWeekday} IS NOT NULL`, + ) + .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-cap.test.ts b/packages/vitnode/src/api/models/notifications/email-cap.test.ts new file mode 100644 index 000000000..f9f76de43 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/email-cap.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { splitByHourlyEmailCap } from "./email-cap"; + +const now = new Date("2026-10-04T12:00:00Z"); + +describe("splitByHourlyEmailCap", () => { + it("sends everything when there is no cap", () => { + const result = splitByHourlyEmailCap({ + cap: 0, + deliveries: [ + { id: 1, mode: "immediate", userId: 7 }, + { id: 2, mode: "immediate", userId: 7 }, + ], + now, + sentInLastHour: new Map([[7, [now, now, now]]]), + }); + + expect(result.allowed).toEqual([1, 2]); + expect(result.deferred.size).toBe(0); + }); + + it("holds immediate emails over the cap until the oldest one leaves the hour", () => { + const result = splitByHourlyEmailCap({ + cap: 2, + deliveries: [ + { id: 1, mode: "immediate", userId: 7 }, + { id: 2, mode: "immediate", userId: 7 }, + { id: 3, mode: "immediate", userId: 8 }, + ], + now, + sentInLastHour: new Map([[7, [new Date("2026-10-04T11:20:00Z")]]]), + }); + + expect(result.allowed).toEqual([1, 3]); + expect(result.deferred.get(2)).toEqual(new Date("2026-10-04T12:20:00Z")); + }); + + it("never holds back digests", () => { + const result = splitByHourlyEmailCap({ + cap: 1, + deliveries: [{ id: 1, mode: "daily", userId: 7 }], + now, + sentInLastHour: new Map([[7, [now]]]), + }); + + expect(result.allowed).toEqual([1]); + }); +}); diff --git a/packages/vitnode/src/api/models/notifications/email-cap.ts b/packages/vitnode/src/api/models/notifications/email-cap.ts new file mode 100644 index 000000000..87d21a076 --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/email-cap.ts @@ -0,0 +1,46 @@ +const HOUR = 60 * 60 * 1000; + +export interface CappedDelivery { + id: number; + mode: string; + userId: number; +} + +export const splitByHourlyEmailCap = ({ + cap, + deliveries, + now, + sentInLastHour, +}: { + cap: number; + deliveries: CappedDelivery[]; + now: Date; + sentInLastHour: Map<number, Date[]>; +}): { allowed: number[]; deferred: Map<number, Date> } => { + const allowed: number[] = []; + const deferred = new Map<number, Date>(); + const used = new Map<number, Date[]>(); + + for (const delivery of deliveries) { + if (cap === 0 || delivery.mode !== "immediate") { + allowed.push(delivery.id); + continue; + } + + const window = used.get(delivery.userId) ?? [ + ...(sentInLastHour.get(delivery.userId) ?? []), + ]; + used.set(delivery.userId, window); + + if (window.length < cap) { + window.push(now); + allowed.push(delivery.id); + continue; + } + + const oldest = window.reduce((min, date) => (date < min ? date : min), now); + deferred.set(delivery.id, new Date(oldest.getTime() + HOUR)); + } + + return { allowed, deferred }; +}; diff --git a/packages/vitnode/src/api/models/notifications/email-queue.ts b/packages/vitnode/src/api/models/notifications/email-queue.ts index 0f9ef053c..481b4471f 100644 --- a/packages/vitnode/src/api/models/notifications/email-queue.ts +++ b/packages/vitnode/src/api/models/notifications/email-queue.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import { core_queue } from "@/database/queue"; @@ -18,17 +18,28 @@ export const dispatchEmailDrain = async ( ): Promise<void> => { const db = tx ?? c.get("db"); const [queued] = await db - .select({ id: core_queue.id }) + .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), - inArray(core_queue.status, ["pending"]), + eq(core_queue.status, "pending"), ), ) + .orderBy(asc(core_queue.availableAt)) .limit(1); - if (queued) return; + 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, diff --git a/packages/vitnode/src/api/models/notifications/email.ts b/packages/vitnode/src/api/models/notifications/email.ts index e46b6504b..d5d1bfff4 100644 --- a/packages/vitnode/src/api/models/notifications/email.ts +++ b/packages/vitnode/src/api/models/notifications/email.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, inArray, isNull, lt, lte, sql } from "drizzle-orm"; +import { and, asc, eq, gt, inArray, isNull, lt, lte, sql } from "drizzle-orm"; import { createElement } from "react"; import type { NotificationTypePreference } from "@/database/notifications"; @@ -29,6 +29,7 @@ import type { NotificationSettingsSnapshot, } from "./shared"; +import { splitByHourlyEmailCap } from "./email-cap"; import { dispatchEmailDrain } from "./email-queue"; import { valuesList } from "./fanout"; import { @@ -38,7 +39,11 @@ import { presentNotificationEmail, resolveEvent, } from "./render"; -import { isEmailConfigured, loadNotificationSettings } from "./shared"; +import { + getNotificationWorkers, + isEmailConfigured, + loadNotificationSettings, +} from "./shared"; type DeliveryRow = typeof core_notification_deliveries.$inferSelect; type DeliveryOutcome = @@ -439,6 +444,75 @@ const runWithConcurrency = async <T>( * In-app delivery never waits on this: email runs in its own queue task, so a * slow or failing provider only delays email. */ +const holdOverHourlyCap = async ( + c: NotificationsContext, + claimed: DeliveryRow[], + settings: NotificationSettingsSnapshot, + now: Date, +): Promise<DeliveryRow[]> => { + const cap = settings.global.emailCapPerHour; + const userIds = [ + ...new Set( + claimed.filter(row => row.mode === "immediate").map(row => row.userId), + ), + ]; + if (cap === 0 || userIds.length === 0) return claimed; + + const db = c.get("db"); + const recent = await db + .select({ + sentAt: core_notification_deliveries.sentAt, + userId: core_notification_deliveries.userId, + }) + .from(core_notification_deliveries) + .where( + and( + eq(core_notification_deliveries.mode, "immediate"), + eq(core_notification_deliveries.status, "sent"), + inArray(core_notification_deliveries.userId, userIds), + gt( + core_notification_deliveries.sentAt, + new Date(now.getTime() - 60 * 60 * 1000), + ), + ), + ); + + const sentInLastHour = new Map<number, Date[]>(); + for (const row of recent) { + if (!row.sentAt) continue; + sentInLastHour.set(row.userId, [ + ...(sentInLastHour.get(row.userId) ?? []), + row.sentAt, + ]); + } + + const { deferred } = splitByHourlyEmailCap({ + cap, + deliveries: claimed, + now, + sentInLastHour, + }); + + for (const [id, availableAt] of deferred) { + await db + .update(core_notification_deliveries) + .set({ + attempts: sql`greatest(${core_notification_deliveries.attempts} - 1, 0)`, + availableAt, + status: "pending", + updatedAt: new Date(), + }) + .where( + and( + eq(core_notification_deliveries.id, id), + eq(core_notification_deliveries.status, "sending"), + ), + ); + } + + return claimed.filter(row => !deferred.has(row.id)); +}; + export const drainNotificationEmails = async ( c: NotificationsContext, { now = new Date() }: { now?: Date } = {}, @@ -450,6 +524,9 @@ export const drainNotificationEmails = async ( }> => { 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 @@ -465,7 +542,7 @@ export const drainNotificationEmails = async ( asc(core_notification_deliveries.availableAt), asc(core_notification_deliveries.id), ) - .limit(settings.global.emailBatchSize) + .limit(getNotificationWorkers(c).emailBatchSize) .for("update", { skipLocked: true }); if (due.length === 0) return []; @@ -487,10 +564,11 @@ export const drainNotificationEmails = async ( const stats = { claimed: claimed.length, failed: 0, sent: 0, skipped: 0 }; const translator = createTranslatorCache(c); + const sendable = await holdOverHourlyCap(c, claimed, settings, now); await runWithConcurrency( - claimed, - settings.global.emailConcurrency, + sendable, + getNotificationWorkers(c).emailConcurrency, async delivery => { let patch: Partial<DeliveryRow>; try { @@ -703,11 +781,11 @@ export const planNotificationDigests = async ( } const period = latestEndedDigestPeriod({ - hour: profile.digestHour ?? 8, + hour: profile.digestHour ?? settings.global.digestHour, mode, now, timeZone: profile.timeZone ?? profile.languageTimeZone ?? "UTC", - weekday: profile.digestWeekday ?? 1, + weekday: profile.digestWeekday ?? settings.global.digestWeekday, }); // Not due yet: it belongs to the period still running. if (receipt.createdAt >= period.end) continue; 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 index a5140fd2b..c373b9e60 100644 --- a/packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts +++ b/packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts @@ -11,7 +11,6 @@ import { core_notification_deliveries, core_notification_events, core_notification_receipts, - core_notification_settings, core_notification_subscriptions, core_notification_user_state, core_notifications, @@ -60,12 +59,9 @@ describePostgres("notification fan-out with 1,000 recipients", () => { h = await createNotificationsHarness({ subjects: [topicSubject], types: [announcementType], + workers: { fanoutBatchSize: BATCH }, }); users = await h.createUsers(RECIPIENTS + 1); - await h.c - .get("db") - .insert(core_notification_settings) - .values({ key: "global", value: { fanoutBatchSize: BATCH } }); // Users 1-600 follow the topic; 400-1000 are named explicitly (overlap 400-600). await h.c diff --git a/packages/vitnode/src/api/models/notifications/fanout.ts b/packages/vitnode/src/api/models/notifications/fanout.ts index 7df59e83d..13b575a93 100644 --- a/packages/vitnode/src/api/models/notifications/fanout.ts +++ b/packages/vitnode/src/api/models/notifications/fanout.ts @@ -26,6 +26,7 @@ import type { import { dispatchEmailDrain } from "./email-queue"; import { getNotificationRegistry, + getNotificationWorkers, isEmailConfigured, loadNotificationSettings, sendNotificationStates, @@ -286,7 +287,7 @@ const processBatch = async ( const candidates = await nextCandidates( tx, event, - settings.global.fanoutBatchSize, + getNotificationWorkers(c).fanoutBatchSize, ); const { eligible, muted } = await filterRecipients({ c, @@ -494,6 +495,7 @@ export interface FanoutResult { batches: number; delivered: number; done: boolean; + paused?: boolean; } /** @@ -511,6 +513,7 @@ export const processNotificationEvent = async ( const settings = await loadNotificationSettings(db); const deadline = Date.now() + timeBudgetMs; const result: FanoutResult = { batches: 0, delivered: 0, done: false }; + if (settings.global.paused) return { ...result, done: true, paused: true }; while (!result.done) { const batch = await db.transaction( diff --git a/packages/vitnode/src/api/models/notifications/maintenance.ts b/packages/vitnode/src/api/models/notifications/maintenance.ts index ac076c5bc..0793c99ba 100644 --- a/packages/vitnode/src/api/models/notifications/maintenance.ts +++ b/packages/vitnode/src/api/models/notifications/maintenance.ts @@ -14,6 +14,7 @@ import { planNotificationDigests, reclaimStaleDeliveries } from "./email"; import { dispatchEmailDrain } from "./email-queue"; import { removeNotificationItems } from "./inbox"; import { + getNotificationWorkers, loadNotificationSettings, NOTIFICATIONS_PLUGIN_ID, QUEUE_NOTIFICATIONS_FANOUT, @@ -36,8 +37,8 @@ export const runNotificationCleanup = async ( { now = new Date() }: { now?: Date } = {}, ): Promise<{ deliveries: number; events: number; items: number }> => { const db = c.get("db"); - const { global } = await loadNotificationSettings(db); - const cutoff = new Date(now.getTime() - global.retentionDays * DAY); + const { retentionDays } = getNotificationWorkers(c); + const cutoff = new Date(now.getTime() - retentionDays * DAY); const items = await removeNotificationItems(c, { where: lt(core_notifications.lastActivityAt, cutoff), @@ -179,6 +180,11 @@ 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 }); diff --git a/packages/vitnode/src/api/models/notifications/preferences.ts b/packages/vitnode/src/api/models/notifications/preferences.ts index da747b535..c3c524d7b 100644 --- a/packages/vitnode/src/api/models/notifications/preferences.ts +++ b/packages/vitnode/src/api/models/notifications/preferences.ts @@ -7,6 +7,8 @@ import type { NotificationEmailMode } from "@/lib/notifications/types"; import { isValidTimeZone } from "@/api/lib/notifications/digest-period"; import { isNotificationEmailAvailable, + isNotificationInAppAvailable, + isNotificationLockedForMembers, resolveNotificationChannels, } from "@/api/lib/notifications/preferences"; import { core_notification_user_state } from "@/database/notifications"; @@ -28,7 +30,9 @@ export interface NotificationTypePreferenceView { /** The modes this type may use here - empty when email is unavailable. */ emailModes: NotificationEmailMode[]; id: string; + inAppAvailable: boolean; label: string; + locked: boolean; mandatory: boolean; pluginId: string; /** What the user gets now, after defaults and installation policy. */ @@ -42,7 +46,7 @@ export interface NotificationPreferencesView { types: NotificationTypePreferenceView[]; } -const translateOr = ( +export const translateOr = ( t: Awaited<ReturnType<ReturnType<typeof createTranslatorCache>>>["t"], keys: string[], fallback: string, @@ -79,14 +83,20 @@ export const getNotificationPreferences = async ( const types = getNotificationRegistry(c) .list.filter(({ definition }) => { const policy = settings.policies.get(definition.id); + if (definition.mandatory) return true; - return policy?.enabled !== false || definition.mandatory; + 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 }) && - !definition.mandatory; + !locked; return { category: definition.category, @@ -105,7 +115,9 @@ export const getNotificationPreferences = async ( ? ["none", "immediate", "daily", "weekly"] : [], id: definition.id, + inAppAvailable: isNotificationInAppAvailable({ definition, policy }), label: translateOr(t, [definition.label], definition.id), + locked, mandatory: !!definition.mandatory, pluginId, value: resolveNotificationChannels({ @@ -119,8 +131,8 @@ export const getNotificationPreferences = async ( }); return { - digestHour: state?.digestHour ?? 8, - digestWeekday: state?.digestWeekday ?? 1, + digestHour: state?.digestHour ?? settings.global.digestHour, + digestWeekday: state?.digestWeekday ?? settings.global.digestWeekday, timeZone: state?.timeZone ?? null, types, }; @@ -161,19 +173,24 @@ export const updateNotificationPreferences = async ( }); } const { definition } = registered; - if (definition.mandatory) { + 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: settings.policies.get(typeId), - }) + !isNotificationEmailAvailable({ definition, emailConfigured, policy }) ) { throw new HTTPException(400, { message: `"${typeId}" has no email channel.`, diff --git a/packages/vitnode/src/api/models/notifications/shared.ts b/packages/vitnode/src/api/models/notifications/shared.ts index 06bd06fd1..10c0b2596 100644 --- a/packages/vitnode/src/api/models/notifications/shared.ts +++ b/packages/vitnode/src/api/models/notifications/shared.ts @@ -10,7 +10,11 @@ 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 } from "@/api/lib/notifications/preferences"; +import { + DEFAULT_NOTIFICATION_SETTINGS, + type NotificationWorkerSettings, + resolveNotificationWorkerSettings, +} from "@/api/lib/notifications/preferences"; import { core_notification_settings } from "@/database/notifications"; import { notificationsStateChannel } from "@/ws/notifications"; @@ -57,34 +61,19 @@ export const normalizeGlobalSettings = ( const defaults = DEFAULT_NOTIFICATION_SETTINGS; return { - emailBatchSize: clampInt( - value?.emailBatchSize, - 1, + digestHour: clampInt(value?.digestHour, 0, 23, defaults.digestHour), + digestWeekday: clampInt(value?.digestWeekday, 0, 6, defaults.digestWeekday), + emailCapPerHour: clampInt( + value?.emailCapPerHour, + 0, 500, - defaults.emailBatchSize, - ), - emailConcurrency: clampInt( - value?.emailConcurrency, - 1, - 20, - defaults.emailConcurrency, + defaults.emailCapPerHour, ), emailEnabled: typeof value?.emailEnabled === "boolean" ? value.emailEnabled : defaults.emailEnabled, - fanoutBatchSize: clampInt( - value?.fanoutBatchSize, - 10, - 5000, - defaults.fanoutBatchSize, - ), - retentionDays: clampInt( - value?.retentionDays, - 1, - 3650, - defaults.retentionDays, - ), + paused: typeof value?.paused === "boolean" ? value.paused : defaults.paused, }; }; @@ -106,6 +95,11 @@ export const loadNotificationSettings = async ( return { global: normalizeGlobalSettings(global), policies }; }; +export const getNotificationWorkers = ( + c: NotificationsContext, +): NotificationWorkerSettings => + c.get("core").notificationWorkers ?? resolveNotificationWorkerSettings(); + /** Whether notification emails can be sent at all on this installation. */ export const isEmailConfigured = ( c: NotificationsContext, 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..a36be0f1f --- /dev/null +++ b/packages/vitnode/src/api/models/notifications/stats.ts @@ -0,0 +1,236 @@ +import { and, gte, inArray, isNull, ne, or, sql } 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<string>(); + 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<NotificationStats, "points" | "previous" | "totals"> => { + const byKey = new Map<string, NotificationStatsTotals>(); + 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<NotificationStats> => { + 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: unknown) => + sql<string>`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<number>`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<number>`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" + ? ("sent" as const) + : row.status === "failed" + ? ("failed" as const) + : ("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/modules/admin/notifications/notifications.admin.module.ts b/packages/vitnode/src/api/modules/admin/notifications/notifications.admin.module.ts index 3f5857db3..da7e7fa51 100644 --- a/packages/vitnode/src/api/modules/admin/notifications/notifications.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/notifications/notifications.admin.module.ts @@ -1,6 +1,14 @@ 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, @@ -15,6 +23,7 @@ import { updateNotificationSettingsRoute, updateNotificationTypePolicyRoute, } from "./routes/settings.route"; +import { getNotificationStatsRoute } from "./routes/stats.route"; export const notificationsAdminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, @@ -28,5 +37,12 @@ export const notificationsAdminModule = buildModule({ sendNotificationTestEmailRoute, reconcileNotificationCountsRoute, runNotificationCleanupRoute, + 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..1ce76da14 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts @@ -0,0 +1,111 @@ +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 permission = { + module: "notifications", + permission: "can_manage", +} as const; + +const json = <T extends z.ZodType>(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: permission, + route: { + method: "post", + description: + "Stop fanning out and sending notifications. New events are still stored and wait until notifications resume.", + path: "/pause", + responses: json(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: permission, + route: { + method: "post", + description: + "Resume notifications and queue every event that waited while they were paused.", + path: "/resume", + responses: json(z.object({ requeuedEvents: z.number() }), "Resumed"), + }, + handler: async c => c.json(await resumeNotifications(c)), +}); + +export const cancelQueuedNotificationEmailsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: permission, + 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: json(z.object({ cancelled: z.number() }), "Cancelled"), + }, + handler: async c => c.json(await cancelQueuedNotificationEmails(c)), +}); + +export const markEverythingReadRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: permission, + route: { + method: "post", + description: + "Mark every unread notification as read for every member and zero their badges.", + path: "/read-all", + responses: json( + 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: permission, + route: { + method: "post", + description: + "Delete every member's notifications. Types, preferences, settings and email history are kept.", + path: "/delete-all", + responses: json( + 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: permission, + 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: json(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/overview.route.ts b/packages/vitnode/src/api/modules/admin/notifications/routes/overview.route.ts index 9407a3b97..4b27cf506 100644 --- a/packages/vitnode/src/api/modules/admin/notifications/routes/overview.route.ts +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/overview.route.ts @@ -8,14 +8,16 @@ import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types"; const zodEmailMode = z.enum(NOTIFICATION_EMAIL_MODES); const zodCounts = z.record(z.string(), z.number()); -export const zodNotificationGlobalSettings = z.object({ - emailBatchSize: z.number().int().min(1).max(500), - emailConcurrency: z.number().int().min(1).max(20), +export const zodEditableNotificationSettings = z.object({ + digestHour: z.number().int().min(0).max(23), + digestWeekday: z.number().int().min(0).max(6), + emailCapPerHour: z.number().int().min(0).max(500), emailEnabled: z.boolean(), - fanoutBatchSize: z.number().int().min(10).max(5000), - retentionDays: z.number().int().min(1).max(3650), }); +export const zodNotificationGlobalSettings = + zodEditableNotificationSettings.extend({ paused: z.boolean() }); + export const getNotificationsOverviewRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, adminStaffPermission: { module: "notifications", permission: "can_view" }, @@ -29,6 +31,12 @@ export const getNotificationsOverviewRoute = buildRoute({ 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(), enabled: z.boolean(), @@ -42,6 +50,12 @@ export const getNotificationsOverviewRoute = buildRoute({ queue: zodCounts, }), settings: zodNotificationGlobalSettings, + workers: z.object({ + emailBatchSize: z.number(), + emailConcurrency: z.number(), + fanoutBatchSize: z.number(), + retentionDays: z.number(), + }), types: z.array( z.object({ category: z.string(), @@ -58,9 +72,12 @@ export const getNotificationsOverviewRoute = buildRoute({ pluginId: z.string(), policy: z.object({ allowEmail: z.boolean(), + allowInApp: z.boolean(), + allowPush: z.boolean(), email: zodEmailMode.nullable(), enabled: z.boolean(), inApp: z.boolean().nullable(), + memberCanEdit: z.boolean(), }), version: z.number(), }), 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 index 6cde443ea..ee35c7eee 100644 --- a/packages/vitnode/src/api/modules/admin/notifications/routes/settings.route.ts +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/settings.route.ts @@ -8,7 +8,10 @@ import { import { CONFIG_PLUGIN } from "@/config"; import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types"; -import { zodNotificationGlobalSettings } from "./overview.route"; +import { + zodEditableNotificationSettings, + zodNotificationGlobalSettings, +} from "./overview.route"; export const updateNotificationSettingsRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, @@ -16,14 +19,14 @@ export const updateNotificationSettingsRoute = buildRoute({ route: { method: "put", description: - "Update installation-wide notification settings: email switch, batch sizes and retention.", + "Update installation-wide notification settings: email switch, hourly email cap and default digest schedule. Retention and worker sizes come from `notifications` in the API config.", path: "/settings", request: { body: { required: true, content: { "application/json": { - schema: zodNotificationGlobalSettings.partial(), + schema: zodEditableNotificationSettings.partial(), }, }, }, @@ -48,7 +51,7 @@ export const updateNotificationTypePolicyRoute = buildRoute({ route: { method: "put", description: - "Set the installation policy for one type: on/off, whether it may email, and the defaults for users who never chose.", + "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) }), @@ -58,9 +61,12 @@ export const updateNotificationTypePolicyRoute = buildRoute({ "application/json": { schema: z.object({ allowEmail: z.boolean().optional(), + allowInApp: z.boolean().optional(), + allowPush: z.boolean().optional(), email: z.enum(NOTIFICATION_EMAIL_MODES).optional(), enabled: z.boolean().optional(), inApp: z.boolean().optional(), + memberCanEdit: z.boolean().optional(), }), }, }, 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/notifications/routes/preferences.route.ts b/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts index af4aac470..7526a30a1 100644 --- a/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts +++ b/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts @@ -24,7 +24,9 @@ const zodPreferences = z.object({ 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(), value: z.object({ email: zodNotificationEmailMode, inApp: z.boolean() }), diff --git a/packages/vitnode/src/database/notifications.ts b/packages/vitnode/src/database/notifications.ts index 57d954959..44f8391b5 100644 --- a/packages/vitnode/src/database/notifications.ts +++ b/packages/vitnode/src/database/notifications.ts @@ -179,9 +179,9 @@ export const core_notification_user_state = camelCase.table.withRLS( unreadCount: t.integer().notNull().default(0), revision: t.bigint({ mode: "number" }).notNull().default(0), timeZone: t.varchar({ length: 64 }), - digestHour: t.smallint().notNull().default(8), + digestHour: t.smallint(), /** 0 = Sunday ... 6 = Saturday. */ - digestWeekday: t.smallint().notNull().default(1), + digestWeekday: t.smallint(), preferences: t .jsonb() .$type<Record<string, NotificationTypePreference>>() diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 5141554f0..e1dec8536 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -39,7 +39,7 @@ "@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": "Retry deliveries, send test emails and run maintenance", + "@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", @@ -1108,7 +1108,8 @@ "unfollowed": "Unfollowed", "unmuted": "Unmuted", "error": "We couldn't update that. Please try again." - } + }, + "locked": "Set by the admin" } } }, @@ -1997,188 +1998,221 @@ "system": { "notifications": { "title": "Notifications", - "desc": "Keep an eye on delivery health, decide which notification types run here, and tidy up when something drifts.", + "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." }, - "status": { - "pending": "Pending", - "processing": "Processing", - "completed": "Completed", - "sending": "Sending", - "sent": "Sent", - "failed": "Failed", - "skipped": "Skipped" + "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" }, - "health": { - "title": "Health", - "email": { - "title": "Notification email", - "ready": "Ready", - "disabled": "Turned off", - "missing": "No adapter", - "ready_desc": "An email adapter is configured and notification emails are on.", - "disabled_desc": "An email adapter is configured, but notification emails are turned off below.", - "missing_desc": "No email adapter is configured, so notifications stay in-app only." + "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." }, - "worker": { - "title": "Background worker", - "active": "Running", - "stale": "Stale", - "inactive": "Not configured", - "active_desc": "Cron is ticking, so fan-out and email batches keep moving.", - "stale_desc": "Cron hasn't run recently - queued work is waiting.", - "inactive_desc": "No cron adapter is configured, so queued work never runs on its own." + "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" }, - "events": { - "title": "Events", - "desc": "Notifications waiting to reach their recipients." + "summary": { + "label": "{type} defaults", + "list": "Notification list", + "push": "Push", + "email": "Email", + "on": "On", + "off": "Off", + "available": "Available", + "disabled": "Disabled", + "unsupported": "Not supported" }, - "deliveries": { - "title": "Email deliveries", - "desc": "Every email the notification system has tried to send." + "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." }, - "oldest": { - "title": "Oldest pending event", - "none": "Nothing waiting", - "none_desc": "The fan-out queue is empty. Lovely.", - "desc": "The longer this gets, the later people hear about things." + "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." }, - "failed_queue": "{count, plural, one {# notification queue task failed} other {# notification queue tasks failed}}", - "warnings": { - "title": "Heads up", - "cron_inactive": "No cron adapter is configured. Notifications are created, but fan-out and emails won't run until it is.", - "cron_stale": "Cron hasn't run recently. Check that your scheduler is still calling the cron endpoint.", - "email_adapter_missing": "No email adapter is configured, so nobody gets notification emails. In-app notifications still work.", - "failed_deliveries": "{count, plural, one {# email delivery failed} other {# email deliveries failed}}. Filter the deliveries below by \"Failed\" to inspect and retry.", - "failed_events": "{count, plural, one {# event failed} other {# events failed}} to fan out. Check the queue tasks for details." - } + "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.}} Muted types, email choices and digest schedules 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": { - "title": "Installation settings", - "desc": "Apply to everyone on this installation.", + "button": "Settings", + "title": "Notification settings", + "desc": "Every change saves on its own and applies to everyone.", "read_only": "You can see these settings, but changing them needs the \"Can edit\" notifications permission.", - "enabled": "On", - "disabled": "Off", - "submit": "Save settings", - "success": "Settings saved", - "success_desc": "The new notification settings apply from the next batch.", + "groups": { + "email": "Email", + "tools": "Tools", + "danger": "Danger zone" + }, + "edit": "Edit {label}", + "save": "Save", + "cancel": "Cancel", + "success": "Saved", + "success_desc": "{label}: {value}.", + "on": "On", + "off": "Off", "emailEnabled": { "label": "Send notification emails", - "desc": "Turn this off to keep every notification in-app, whatever people picked." - }, - "retentionDays": { - "label": "Keep notifications for", - "desc": "Older notifications are removed by the nightly cleanup. Between 1 and 3650 days.", - "unit": "days" - }, - "fanoutBatchSize": { - "label": "Fan-out batch size", - "desc": "Recipients handled per fan-out step. Between 10 and 5000." - }, - "emailBatchSize": { - "label": "Email batch size", - "desc": "Deliveries picked up per email run. Between 1 and 500." - }, - "emailConcurrency": { - "label": "Email concurrency", - "desc": "Emails sent in parallel during a run. Between 1 and 20." - } - }, - "types": { - "title": "Notification types", - "desc": "Every type registered by core and your plugins. Defaults apply to people who never changed their preferences.", - "empty": { - "title": "No notification types yet", - "desc": "Core and plugins register their types here. Nothing has signed up so far." + "desc": "Off keeps everything in the notification list, whatever members picked." }, - "in_app": "In-app", - "email": "Email", - "email_unavailable": "Email unavailable", - "grouped": "Grouped", - "mandatory": "Mandatory", - "version": "v{version}", - "enabled": "Enabled", - "enabled_mandatory": "Mandatory types are always on.", - "allow_email": "Allow email", - "default_in_app": "In-app by default", - "default_email": "Default email", - "email_modes": { - "none": "None", - "immediate": "Immediately", - "daily": "Daily digest", - "weekly": "Weekly digest" + "emailCapPerHour": { + "label": "Emails per member per hour", + "desc": "Immediate emails over the limit wait until the hour is up. 0 means no limit.", + "none": "No limit" }, - "read_only": "Changing type policies needs the \"Can edit\" notifications permission.", - "success": "Type updated", - "success_desc": "\"{type}\" now follows the new policy." - }, - "deliveries": { - "title": "Email deliveries", - "desc": "Newest first. Recipients show as user ids - addresses and content never leave the server.", - "filter": "Status", - "all": "All", - "caption": "Email delivery records", - "id": "ID", - "type": "Type", - "mode": "Mode", - "state": "Status", - "attempts": "Attempts", - "error": "Error", - "user": "User", - "updated": "Updated", - "actions": "Actions", - "user_id": "#{id}", - "test_type": "Test email", - "items": "{count, plural, one {# item} other {# items}}", - "modes": { - "immediate": "Immediate", - "daily": "Daily", - "weekly": "Weekly", - "test": "Test" + "digestHour": { + "label": "Daily digest time", + "desc": "For members who haven't picked their own, in their time zone." }, - "load_more": "Load more", - "empty": { - "title": "No deliveries here", - "desc": "Nothing matches this filter. Quiet inboxes are happy inboxes." + "digestWeekday": { + "label": "Weekly digest day", + "desc": "For members who haven't picked their own." }, - "retry": { - "label": "Retry delivery #{id}", - "success": "Delivery queued again", - "success_desc": "Delivery #{id} goes out with the next email run, using the same idempotency key.", - "conflict": "Only failed deliveries can be retried. This one has already moved on." - } + "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." }, - "maintenance": { - "title": "Maintenance", - "desc": "Handy one-off jobs for when something looks off.", - "read_only": "Maintenance tools need the \"Can manage\" notifications permission.", - "retention": "Notifications older than {days, plural, one {# day} other {# days}} are cleaned up every night. Running it now just skips the wait.", + "tools": { + "run": "Run", + "run_label": "Run: {label}", "test_email": { - "label": "Send test email to me", - "desc": "Queues a sample notification email to your own address - and only yours.", + "label": "Send a test email to me", + "desc": "Goes to your own address, and only yours.", "success": "Test email queued", - "success_desc": "Delivery #{id} is on its way to your own address. Watch it in the deliveries list below.", - "not_configured": "Notification email isn't configured yet, so there's nothing to test." - }, - "reconcile": { - "title": "Unread counts", - "desc": "Compares each person's stored unread badge with their actual inbox.", - "check": "Check unread counts", - "fix": "Fix unread counts", - "result_ok": "All unread counts match. Nothing to fix.", - "result_mismatched": "{count, plural, one {# person has} other {# people have}} a stored unread count that doesn't match their inbox.", - "confirm_title": "Fix unread counts?", - "confirm_desc": "Every mismatched unread count is recalculated from the inbox. People's badges update right away.", - "confirm_submit": "Fix counts", - "fixed": "Unread counts fixed", - "fixed_desc": "{count, plural, =0 {Everything already matched.} one {# person's count was corrected.} other {# people's counts were corrected.}}" + "success_desc": "Delivery #{id} goes to your own address.", + "not_configured": "Notification email isn't set up yet, so there's nothing to test." }, "cleanup": { "label": "Run cleanup now", - "success": "Cleanup queued", - "success_desc": "Old notifications are removed by the next queue run." + "desc": "Removes notifications older than {count, plural, one {# day} other {# days}} now instead of tonight.", + "success": "Cleanup started", + "success_desc": "Old notifications are being removed in the background." + } + }, + "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 <phrase></phrase> 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.}}" } } }, diff --git a/packages/vitnode/src/tanstack/admin/notifications/index.ts b/packages/vitnode/src/tanstack/admin/notifications/index.ts index a1af8a402..3ce552021 100644 --- a/packages/vitnode/src/tanstack/admin/notifications/index.ts +++ b/packages/vitnode/src/tanstack/admin/notifications/index.ts @@ -14,13 +14,12 @@ export type { NotificationTypePolicyPatch, } from "@/views/admin/views/core/system/notifications/notifications-mutations"; export type { - AdminNotificationDeliveriesPage, - AdminNotificationDelivery, AdminNotificationsOverview, + AdminNotificationStats, AdminNotificationType, } from "@/views/admin/views/core/system/notifications/notifications-query"; export { - notificationDeliveriesQueryOptions, 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 index 2391c6e43..291cd7768 100644 --- a/packages/vitnode/src/tanstack/admin/notifications/query.ts +++ b/packages/vitnode/src/tanstack/admin/notifications/query.ts @@ -8,7 +8,7 @@ import type { NotificationsAdminActions } from "@/views/admin/views/core/system/ import { notificationsAdminActionsInBrowser } from "@/views/admin/views/core/system/notifications/notifications-mutations"; import { notificationsAdminQueryRoot } from "@/views/admin/views/core/system/notifications/notifications-query"; -/** Refreshes the overview and every cached page of deliveries. */ +/** Refreshes the overview and every cached activity range. */ export const invalidateNotificationsAdmin = async ( queryClient: QueryClient, ): Promise<void> => @@ -40,9 +40,13 @@ export const useNotificationsAdminActions = (): NotificationsAdminActions => { const browser = notificationsAdminActionsInBrowser; return { + cancelQueuedEmails: refreshing(browser.cancelQueuedEmails), cleanup: refreshing(browser.cleanup), - reconcile: refreshing(browser.reconcile), - retryDelivery: refreshing(browser.retryDelivery), + deleteAll: refreshing(browser.deleteAll), + markEverythingRead: refreshing(browser.markEverythingRead), + pause: refreshing(browser.pause), + resetMemberPreferences: refreshing(browser.resetMemberPreferences), + resume: refreshing(browser.resume), sendTestEmail: refreshing(browser.sendTestEmail), updateSettings: refreshing(browser.updateSettings), updateTypePolicy: refreshing(browser.updateTypePolicy), diff --git a/packages/vitnode/src/tanstack/admin/notifications/route.tsx b/packages/vitnode/src/tanstack/admin/notifications/route.tsx index 1fff96442..e03ec5bf6 100644 --- a/packages/vitnode/src/tanstack/admin/notifications/route.tsx +++ b/packages/vitnode/src/tanstack/admin/notifications/route.tsx @@ -1,12 +1,13 @@ import type { PluginRouteTranslator } from "@/routing"; import { - notificationDeliveriesQueryOptions, 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"; /** @@ -47,8 +48,11 @@ export const loadAdminNotificationsRoute = async ({ ...notificationsOverviewQueryOptions(), staleTime: "static", }), - queryClient.infiniteQuery({ - ...notificationDeliveriesQueryOptions({}), + queryClient.query({ + ...notificationStatsQueryOptions({ + range: "7d", + timeZone: getIntlRuntime().timeZone ?? "UTC", + }), staleTime: "static", }), ]); diff --git a/packages/vitnode/src/tanstack/admin/notifications/screen.tsx b/packages/vitnode/src/tanstack/admin/notifications/screen.tsx index 4181751df..730adbd88 100644 --- a/packages/vitnode/src/tanstack/admin/notifications/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/notifications/screen.tsx @@ -4,6 +4,7 @@ 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"; @@ -32,7 +33,17 @@ export const AdminNotificationsRouteContent = ({ return ( <RouteMessages namespaces={ADMIN_NOTIFICATIONS_NAMESPACES}> <div className="p-6"> - <PageTitle desc={description} h1={title} /> + <PageTitle desc={description} h1={title}> + <NotificationsSettingsSheet + actions={actions} + canEdit={canEdit} + canManage={canManage} + counts={data.counts} + emailConfigured={data.email.adapterConfigured && data.email.enabled} + settings={data.settings} + workers={data.workers} + /> + </PageTitle> <NotificationsContent actions={actions} diff --git a/packages/vitnode/src/tanstack/notifications/settings-screen.tsx b/packages/vitnode/src/tanstack/notifications/settings-screen.tsx index 4b06ebb9b..cb004c7af 100644 --- a/packages/vitnode/src/tanstack/notifications/settings-screen.tsx +++ b/packages/vitnode/src/tanstack/notifications/settings-screen.tsx @@ -122,6 +122,11 @@ const PreferenceRow = ({ <LockIcon /> {t("mandatory")} </Badge> + ) : type.locked ? ( + <Badge variant="secondary"> + <LockIcon /> + {t("locked")} + </Badge> ) : null} </span> {type.description ? ( @@ -132,15 +137,17 @@ const PreferenceRow = ({ </div> <div className="flex shrink-0 flex-wrap items-center gap-4"> - <div className="flex items-center gap-2"> - <Switch - checked={type.value.inApp} - disabled={type.mandatory} - id={switchId} - onCheckedChange={checked => onChange({ inApp: checked })} - /> - <Label htmlFor={switchId}>{t("in_app")}</Label> - </div> + {type.inAppAvailable ? ( + <div className="flex items-center gap-2"> + <Switch + checked={type.value.inApp} + disabled={type.locked} + id={switchId} + onCheckedChange={checked => onChange({ inApp: checked })} + /> + <Label htmlFor={switchId}>{t("in_app")}</Label> + </div> + ) : null} {modes.length > 0 ? ( <div className="flex items-center gap-2"> @@ -168,7 +175,7 @@ const PreferenceRow = ({ </div> ) : ( <span className="text-muted-foreground text-sm"> - {type.mandatory && type.value.email !== "none" + {type.locked && type.value.email !== "none" ? t(`email_modes.${type.value.email}`) : t("email_unavailable")} </span> @@ -207,9 +214,12 @@ const ScheduleForm = ({ }); const onSubmit: AutoFormOnSubmit<typeof formSchema> = async values => { + const digestHour = Number(values.digestHour); + const digestWeekday = Number(values.digestWeekday); + await save({ - digestHour: Number(values.digestHour), - digestWeekday: Number(values.digestWeekday), + ...(digestHour === preferences.digestHour ? {} : { digestHour }), + ...(digestWeekday === preferences.digestWeekday ? {} : { digestWeekday }), timeZone: values.timeZone, }); }; diff --git a/packages/vitnode/src/tests/notifications.ts b/packages/vitnode/src/tests/notifications.ts index 0e8798b46..39a500cb0 100644 --- a/packages/vitnode/src/tests/notifications.ts +++ b/packages/vitnode/src/tests/notifications.ts @@ -11,6 +11,10 @@ import type { 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"; @@ -49,6 +53,7 @@ export interface NotificationsHarness { database: TestDatabaseHandle; /** Runs queued tasks until nothing due is left. */ drainQueue: (now?: Date) => Promise<number>; + emitted: { name: string; payload: unknown }[]; /** A context on its own connection pool, for concurrent work. */ forkContext: () => Context<EnvVitNode>; /** Queries sent through any connection since the harness was created. */ @@ -73,11 +78,13 @@ export const createNotificationsHarness = async ({ messages = {}, subjects = [], types, + workers, }: { email?: boolean; messages?: Record<string, unknown>; subjects?: NotificationSubjectDefinition[]; types: AnyNotificationTypeDefinition[]; + workers?: Partial<NotificationWorkerSettings>; }): Promise<NotificationsHarness> => { let queries = 0; const database = await createTestDatabase( @@ -100,6 +107,7 @@ export const createNotificationsHarness = async ({ .values({ id: 1, name: "Member", root: false } as never); const realtime: RealtimeRecord[] = []; + const emitted: NotificationsHarness["emitted"] = []; const sentEmails: SentEmailRecord[] = []; let emailFailure: Error | null = null; const registry = createNotificationRegistry( @@ -128,6 +136,7 @@ export const createNotificationsHarness = async ({ : undefined, metadata: { title: "Test" }, notifications: registry, + notificationWorkers: resolveNotificationWorkerSettings(workers), queue: notificationTasks, }); vars.set("realtime", { @@ -181,6 +190,18 @@ export const createNotificationsHarness = 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)); @@ -215,6 +236,7 @@ export const createNotificationsHarness = async ({ return rows.map(row => row.id); }, database, + emitted, drainQueue: async (now = new Date()) => { let runs = 0; for (let round = 0; round < 200; round++) { diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/activity-content.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/activity-content.tsx new file mode 100644 index 000000000..ae10a0f59 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/activity-content.tsx @@ -0,0 +1,349 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { cn } from "cn"; +import { ArrowDownRightIcon, ArrowUpRightIcon } from "lucide-react"; +import { + AnimatePresence, + motion, + type TargetAndTransition, + type Transition, + useReducedMotion, +} from "motion/react"; +import React from "react"; +import { Area, AreaChart } from "recharts"; +import { useFormatter, useTimeZone, useTranslations } from "use-intl"; + +import type { ChartConfig } from "@/components/ui/chart"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { ChartContainer } from "@/components/ui/chart"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +import type { + AdminNotificationStats, + NotificationStatsRange, + NotificationStatsTotals, +} from "./notifications-query"; + +import { + NOTIFICATION_STATS_RANGES, + notificationStatsQueryOptions, +} from "./notifications-query"; + +type Metric = "events" | "failureRate" | "sent" | "skipped"; + +const isRange = (value: unknown): value is NotificationStatsRange => + (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<Metric, string>; + +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 SWAP_TRANSITION = { + duration: 0.2, + ease: [0.32, 0.72, 0, 1], +} satisfies Transition; + +const Swap = ({ + children, + swapKey, +}: { + children: React.ReactNode; + swapKey: string; +}) => { + const shouldReduceMotion = useReducedMotion(); + + return ( + <span className="relative block"> + <AnimatePresence initial={false} mode="popLayout"> + <motion.span + animate={SWAP_SHOWN} + className="block" + exit={shouldReduceMotion ? undefined : SWAP_EXIT} + initial={shouldReduceMotion ? false : SWAP_HIDDEN} + key={swapKey} + transition={SWAP_TRANSITION} + > + {children} + </motion.span> + </AnimatePresence> + </span> + ); +}; + +const Sparkline = ({ + animate, + metric, + points, +}: { + animate: boolean; + metric: Metric; + points: AdminNotificationStats["points"]; +}) => { + const id = React.useId().replace(/:/g, ""); + const color = TONE[metric]; + const config = { value: { color } } satisfies ChartConfig; + const data = points.map(point => ({ value: valueOf(point, metric) })); + + return ( + <ChartContainer + aria-hidden + className="aspect-auto h-10 w-full" + config={config} + > + <AreaChart data={data} margin={{ bottom: 2, left: 0, right: 0, top: 2 }}> + <defs> + <linearGradient id={`spark-${id}`} x1="0" x2="0" y1="0" y2="1"> + <stop offset="0%" stopColor={color} stopOpacity={0.2} /> + <stop offset="100%" stopColor={color} stopOpacity={0} /> + </linearGradient> + </defs> + <Area + animationDuration={300} + animationEasing="ease-out" + dataKey="value" + fill={`url(#spark-${id})`} + isAnimationActive={animate} + stroke={color} + strokeWidth={1.5} + type="monotone" + /> + </AreaChart> + </ChartContainer> + ); +}; + +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 ( + <span className="text-muted-foreground text-xs">{t("no_previous")}</span> + ); + } + + const change = (current - previous) / previous; + const up = change >= 0; + const Icon = up ? ArrowUpRightIcon : ArrowDownRightIcon; + + return ( + <span + className={cn( + "flex items-center gap-1 text-xs font-medium tabular-nums", + up === goodWhenUp ? "text-success" : "text-destructive", + )} + > + <Icon aria-hidden className="size-3.5" /> + {format.number(Math.abs(change), { + maximumFractionDigits: 1, + style: "percent", + })} + <span className="text-muted-foreground font-normal"> + {t("vs_previous")} + </span> + </span> + ); +}; + +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 value = + metric === "failureRate" + ? format.number(current / 100, { + maximumFractionDigits: 2, + style: "percent", + }) + : format.number(current); + + return ( + <Card className="gap-2 py-4" size="sm"> + <CardContent className="flex flex-col gap-2"> + <p className="text-muted-foreground text-sm">{label}</p> + <p className="text-2xl font-semibold tracking-tight tabular-nums"> + <Swap swapKey={value}>{value}</Swap> + </p> + <Swap swapKey={`${current}:${valueOf(stats.previous, metric)}`}> + <Delta + current={current} + goodWhenUp={goodWhenUp} + previous={valueOf(stats.previous, metric)} + /> + </Swap> + <Sparkline animate={animate} metric={metric} points={stats.points} /> + </CardContent> + </Card> + ); +}; + +const TilesSkeleton = () => ( + <div aria-hidden className="grid grid-cols-2 gap-3 lg:grid-cols-4"> + {["a", "b", "c", "d"].map(key => ( + <Skeleton className="h-36 rounded-xl" key={key} /> + ))} + </div> +); + +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<NotificationStatsRange>("7d"); + const [hasSwitched, setHasSwitched] = React.useState(false); + const shouldReduceMotion = useReducedMotion(); + const animate = hasSwitched && !shouldReduceMotion; + const query = useQuery({ + ...notificationStatsQueryOptions({ range, timeZone }), + placeholderData: keepPreviousData, + }); + + return ( + <section + aria-labelledby="notifications-activity" + className="flex flex-col gap-4" + > + <div className="flex flex-wrap items-center justify-between gap-3"> + <div className="flex flex-col gap-1"> + <h2 + className="text-lg font-semibold text-balance" + id="notifications-activity" + > + {t("title")} + </h2> + <p className="text-muted-foreground text-sm"> + {t(`ranges.${range}`)} + </p> + </div> + <Tabs + onValueChange={value => { + if (!isRange(value)) return; + setRange(value); + setHasSwitched(true); + }} + value={range} + > + <TabsList aria-label={t("range_label")}> + {NOTIFICATION_STATS_RANGES.map(key => ( + <TabsTrigger + aria-label={t(`ranges.${key}`)} + key={key} + value={key} + > + {t(`ranges_short.${key}`)} + </TabsTrigger> + ))} + </TabsList> + </Tabs> + </div> + + {query.data ? ( + <div + aria-busy={query.isFetching} + className={cn( + "grid grid-cols-2 gap-3 transition-opacity duration-150 ease-out motion-reduce:transition-none lg:grid-cols-4", + query.isPlaceholderData && "opacity-60", + )} + > + <StatTile + animate={animate} + goodWhenUp + label={t("sent")} + metric="sent" + stats={query.data} + /> + <StatTile + animate={animate} + goodWhenUp={false} + label={t("failure_rate")} + metric="failureRate" + stats={query.data} + /> + <StatTile + animate={animate} + goodWhenUp + label={t("events")} + metric="events" + stats={query.data} + /> + <StatTile + animate={animate} + goodWhenUp={false} + label={t("skipped")} + metric="skipped" + stats={query.data} + /> + </div> + ) : query.isError ? ( + <Alert variant="destructive"> + <AlertTitle>{tPage("title")}</AlertTitle> + <AlertDescription className="flex flex-col items-start gap-2"> + <p>{tPage("desc")}</p> + <Button + onClick={() => { + void query.refetch(); + }} + size="sm" + variant="outline" + > + {tError("try_again")} + </Button> + </AlertDescription> + </Alert> + ) : ( + <TilesSkeleton /> + )} + </section> + ); +}; 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 ( + <AlertDialog onOpenChange={setOpen} open={open}> + <AlertDialogTrigger render={trigger} /> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>{`${t("title")}?`}</AlertDialogTitle> + <AlertDialogDescription className="leading-relaxed text-pretty"> + {t("confirm_desc", { count })} + </AlertDialogDescription> + </AlertDialogHeader> + <AutoForm + fields={[ + { + id: "confirmation", + component: props => ( + <AutoFormInput + {...props} + autoCapitalize="off" + autoComplete="off" + autoCorrect="off" + className="text-base sm:text-sm" + label={t.rich("phrase_label", { + phrase: () => ( + <strong className="font-mono font-medium select-all"> + {phrase} + </strong> + ), + })} + spellCheck={false} + /> + ), + }, + ]} + formSchema={formSchema} + layout={rendered => ( + <div className="flex flex-col gap-4"> + {rendered.confirmation} + <AlertDialogFooter> + <Button + onClick={() => { + setOpen(false); + }} + type="button" + variant="outline" + > + {tConfirm("cancel")} + </Button> + <Button + isLoading={isDeleting} + type="submit" + variant="destructive" + > + {t("submit")} + </Button> + </AlertDialogFooter> + </div> + )} + 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 }), + }); + }} + /> + </AlertDialogContent> + </AlertDialog> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/deliveries-content.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/deliveries-content.tsx deleted file mode 100644 index 070e48bd4..000000000 --- a/packages/vitnode/src/views/admin/views/core/system/notifications/deliveries-content.tsx +++ /dev/null @@ -1,328 +0,0 @@ -import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; -import { InboxIcon, RotateCwIcon } from "lucide-react"; -import React from "react"; -import { toast } from "sonner"; -import { useTranslations } from "use-intl"; - -import { DateFormat } from "@/components/date-format"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@/components/ui/empty"; -import { Label } from "@/components/ui/label"; -import { - NativeSelect, - NativeSelectOption, -} from "@/components/ui/native-select"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - Table, - TableBody, - TableCaption, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { TooltipWithContent } from "@/components/ui/tooltip"; - -import type { NotificationsAdminActions } from "./notifications-mutations"; -import type { AdminNotificationDelivery } from "./notifications-query"; -import type { NotificationDeliveryStatus } from "./statuses"; - -import { DeliveryStatusBadge } from "./delivery-status-badge"; -import { notificationDeliveriesQueryOptions } from "./notifications-query"; -import { NOTIFICATION_DELIVERY_STATUSES } from "./statuses"; - -const ALL = "all"; - -const isDeliveryStatus = (value: string): value is NotificationDeliveryStatus => - (NOTIFICATION_DELIVERY_STATUSES as readonly string[]).includes(value); - -const RetryDeliveryButton = ({ - id, - onRetry, -}: { - id: number; - onRetry: NotificationsAdminActions["retryDelivery"]; -}) => { - const t = useTranslations("admin.system.notifications.deliveries.retry"); - const tError = useTranslations("core.global.errors"); - const [, formAction, isPending] = React.useActionState(async () => { - const mutation = await onRetry(id); - - if (mutation.error !== undefined) { - toast.error(tError("title"), { - description: - mutation.status === 409 - ? t("conflict") - : tError("internal_server_error"), - }); - - return null; - } - - toast.success(t("success"), { description: t("success_desc", { id }) }); - - return null; - }, null); - - return ( - <form action={formAction}> - <TooltipWithContent text={t("label", { id })}> - <Button - aria-label={t("label", { id })} - isLoading={isPending} - size="icon" - type="submit" - variant="ghost" - > - <RotateCwIcon aria-hidden /> - </Button> - </TooltipWithContent> - </form> - ); -}; - -const DeliveryRow = ({ - canManage, - delivery, - onRetry, -}: { - canManage: boolean; - delivery: AdminNotificationDelivery; - onRetry: NotificationsAdminActions["retryDelivery"]; -}) => { - const t = useTranslations("admin.system.notifications.deliveries"); - const problem = delivery.lastError ?? delivery.skipReason; - - return ( - <TableRow> - <TableCell className="tabular-nums">{delivery.id}</TableCell> - <TableCell> - <div className="flex max-w-xs flex-col gap-1"> - <span className="truncate font-mono text-xs"> - {delivery.type ?? (delivery.mode === "test" ? t("test_type") : "—")} - </span> - <span className="text-muted-foreground text-xs"> - {t("items", { count: delivery.itemCount })} - </span> - </div> - </TableCell> - <TableCell>{t(`modes.${delivery.mode}`)}</TableCell> - <TableCell> - <DeliveryStatusBadge status={delivery.status} /> - </TableCell> - <TableCell className="tabular-nums"> - {delivery.attempts}/{delivery.maxAttempts} - </TableCell> - <TableCell> - {problem ? ( - <span - className={ - delivery.lastError - ? "text-destructive line-clamp-2 max-w-xs text-sm whitespace-normal" - : "text-muted-foreground line-clamp-2 max-w-xs text-sm whitespace-normal" - } - title={problem} - > - {problem} - </span> - ) : ( - <span className="text-muted-foreground">—</span> - )} - </TableCell> - <TableCell className="tabular-nums"> - {t("user_id", { id: delivery.userId })} - </TableCell> - <TableCell> - <DateFormat date={delivery.updatedAt} /> - </TableCell> - <TableCell className="text-end"> - {canManage && delivery.status === "failed" ? ( - <RetryDeliveryButton id={delivery.id} onRetry={onRetry} /> - ) : null} - </TableCell> - </TableRow> - ); -}; - -const DeliveriesSkeleton = () => ( - <div aria-hidden className="flex flex-col gap-2"> - {["a", "b", "c", "d"].map(id => ( - <Skeleton className="h-10 w-full" key={id} /> - ))} - </div> -); - -const DeliveriesTable = ({ - canManage, - onRetry, - status, -}: { - canManage: boolean; - onRetry: NotificationsAdminActions["retryDelivery"]; - status?: NotificationDeliveryStatus; -}) => { - const t = useTranslations("admin.system.notifications.deliveries"); - const tPage = useTranslations("admin.system.notifications.load_error"); - const tError = useTranslations("core.global.errors"); - const query = useInfiniteQuery({ - ...notificationDeliveriesQueryOptions({ status }), - placeholderData: keepPreviousData, - }); - - if (query.isPending) return <DeliveriesSkeleton />; - - if (query.isError) { - return ( - <Alert variant="destructive"> - <AlertTitle>{tPage("title")}</AlertTitle> - <AlertDescription className="flex flex-col items-start gap-2"> - <p>{tPage("desc")}</p> - <Button - onClick={() => { - void query.refetch(); - }} - size="sm" - variant="outline" - > - {tError("try_again")} - </Button> - </AlertDescription> - </Alert> - ); - } - - const deliveries = query.data.pages.flatMap(page => page.items); - - if (deliveries.length === 0) { - return ( - <Empty> - <EmptyHeader> - <EmptyMedia variant="icon"> - <InboxIcon aria-hidden /> - </EmptyMedia> - <EmptyTitle>{t("empty.title")}</EmptyTitle> - <EmptyDescription>{t("empty.desc")}</EmptyDescription> - </EmptyHeader> - </Empty> - ); - } - - return ( - <div - aria-busy={query.isFetching} - className="flex flex-col items-center gap-4" - > - <Table> - <TableCaption className="sr-only">{t("caption")}</TableCaption> - <TableHeader> - <TableRow> - <TableHead>{t("id")}</TableHead> - <TableHead>{t("type")}</TableHead> - <TableHead>{t("mode")}</TableHead> - <TableHead>{t("state")}</TableHead> - <TableHead>{t("attempts")}</TableHead> - <TableHead>{t("error")}</TableHead> - <TableHead>{t("user")}</TableHead> - <TableHead>{t("updated")}</TableHead> - <TableHead> - <span className="sr-only">{t("actions")}</span> - </TableHead> - </TableRow> - </TableHeader> - <TableBody> - {deliveries.map(delivery => ( - <DeliveryRow - canManage={canManage} - delivery={delivery} - key={delivery.id} - onRetry={onRetry} - /> - ))} - </TableBody> - </Table> - - {query.hasNextPage ? ( - <Button - isLoading={query.isFetchingNextPage} - onClick={() => { - void query.fetchNextPage(); - }} - variant="outline" - > - {t("load_more")} - </Button> - ) : null} - </div> - ); -}; - -export const NotificationsDeliveriesCard = ({ - canManage, - onRetry, -}: { - canManage: boolean; - onRetry: NotificationsAdminActions["retryDelivery"]; -}) => { - const t = useTranslations("admin.system.notifications.deliveries"); - const tStatus = useTranslations("admin.system.notifications.status"); - const filterId = React.useId(); - const [status, setStatus] = React.useState<NotificationDeliveryStatus>(); - - return ( - <Card aria-labelledby="notifications-deliveries" role="region"> - <CardHeader> - <CardTitle> - <h2 className="text-balance" id="notifications-deliveries"> - {t("title")} - </h2> - </CardTitle> - <CardDescription className="leading-relaxed text-pretty"> - {t("desc")} - </CardDescription> - </CardHeader> - <CardContent className="flex flex-col gap-4"> - <div className="flex items-center gap-2"> - <Label className="font-normal" htmlFor={filterId}> - {t("filter")} - </Label> - <NativeSelect - id={filterId} - onChange={event => { - const next = event.target.value; - setStatus(isDeliveryStatus(next) ? next : undefined); - }} - size="sm" - value={status ?? ALL} - > - <NativeSelectOption value={ALL}>{t("all")}</NativeSelectOption> - {NOTIFICATION_DELIVERY_STATUSES.map(option => ( - <NativeSelectOption key={option} value={option}> - {tStatus(option)} - </NativeSelectOption> - ))} - </NativeSelect> - </div> - <DeliveriesTable - canManage={canManage} - onRetry={onRetry} - status={status} - /> - </CardContent> - </Card> - ); -}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/delivery-status-badge.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/delivery-status-badge.tsx deleted file mode 100644 index a1febd976..000000000 --- a/packages/vitnode/src/views/admin/views/core/system/notifications/delivery-status-badge.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { - CircleCheckIcon, - CircleMinusIcon, - CircleXIcon, - ClockIcon, - LoaderIcon, -} from "lucide-react"; -import { useTranslations } from "use-intl"; - -import { Badge } from "@/components/ui/badge"; - -import type { NotificationDeliveryStatus } from "./statuses"; - -export const DeliveryStatusBadge = ({ - status, -}: { - status: NotificationDeliveryStatus; -}) => { - const t = useTranslations("admin.system.notifications.status"); - - if (status === "sent") { - return ( - <Badge variant="success"> - <CircleCheckIcon aria-hidden /> {t("sent")} - </Badge> - ); - } - - if (status === "failed") { - return ( - <Badge variant="destructive"> - <CircleXIcon aria-hidden /> {t("failed")} - </Badge> - ); - } - - if (status === "sending") { - return ( - <Badge variant="secondary"> - <LoaderIcon aria-hidden className="motion-safe:animate-spin" />{" "} - {t("sending")} - </Badge> - ); - } - - if (status === "skipped") { - return ( - <Badge variant="outline"> - <CircleMinusIcon aria-hidden /> {t("skipped")} - </Badge> - ); - } - - return ( - <Badge variant="secondary"> - <ClockIcon aria-hidden /> {t("pending")} - </Badge> - ); -}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/health-content.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/health-content.tsx deleted file mode 100644 index 3a03d15a9..000000000 --- a/packages/vitnode/src/views/admin/views/core/system/notifications/health-content.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { - ClockIcon, - HourglassIcon, - type LucideIcon, - MailIcon, - SendIcon, - ZapIcon, -} from "lucide-react"; -import { useTranslations } from "use-intl"; - -import { DateFormat } from "@/components/date-format"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; - -import type { - NotificationEmailHealth, - NotificationHealthSummary, - NotificationWorkerHealth, -} from "./health"; - -import { - NOTIFICATION_DELIVERY_STATUSES, - NOTIFICATION_EVENT_STATUSES, -} from "./statuses"; - -const EMAIL_BADGE = { - disabled: "secondary", - missing: "warning", - ready: "success", -} as const satisfies Record<NotificationEmailHealth, string>; - -const WORKER_BADGE = { - active: "success", - inactive: "destructive", - stale: "warning", -} as const satisfies Record<NotificationWorkerHealth, string>; - -const HealthCard = ({ - badge, - children, - description, - Icon, - title, -}: { - badge?: React.ReactNode; - children?: React.ReactNode; - description: string; - Icon: LucideIcon; - title: string; -}) => ( - <Card size="sm"> - <CardHeader> - <CardTitle className="flex items-center gap-2 text-balance"> - <Icon aria-hidden className="text-muted-foreground size-4" /> - {title} - </CardTitle> - {badge ? <CardAction>{badge}</CardAction> : null} - <CardDescription className="leading-relaxed text-pretty"> - {description} - </CardDescription> - </CardHeader> - {children ? <CardContent>{children}</CardContent> : null} - </Card> -); - -const CountList = <S extends string>({ - counts, - label, - statuses, -}: { - counts: Record<S, number>; - label: (status: S) => string; - statuses: readonly S[]; -}) => ( - <dl className="grid grid-cols-2 gap-2"> - {statuses.map(status => ( - <div className="bg-muted flex flex-col gap-1 rounded-md p-2" key={status}> - <dt className="text-muted-foreground text-xs">{label(status)}</dt> - <dd className="text-lg font-semibold tabular-nums"> - {counts[status].toLocaleString()} - </dd> - </div> - ))} - </dl> -); - -export const NotificationsHealthContent = ({ - oldestPendingEventAt, - summary, -}: { - oldestPendingEventAt: Date | null | string; - summary: NotificationHealthSummary; -}) => { - const t = useTranslations("admin.system.notifications.health"); - const tStatus = useTranslations("admin.system.notifications.status"); - - return ( - <section - aria-labelledby="notifications-health" - className="flex flex-col gap-4" - > - <h2 - className="text-lg font-semibold text-balance" - id="notifications-health" - > - {t("title")} - </h2> - - {summary.warnings.length > 0 ? ( - <Alert variant="warning"> - <AlertTitle>{t("warnings.title")}</AlertTitle> - <AlertDescription> - <ul className="flex list-disc flex-col gap-1 ps-4"> - {summary.warnings.map(warning => ( - <li key={warning.kind}> - {"count" in warning - ? t(`warnings.${warning.kind}`, { count: warning.count }) - : t(`warnings.${warning.kind}`)} - </li> - ))} - </ul> - </AlertDescription> - </Alert> - ) : null} - - <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> - <HealthCard - badge={ - <Badge variant={EMAIL_BADGE[summary.email]}> - {t(`email.${summary.email}`)} - </Badge> - } - description={t(`email.${summary.email}_desc`)} - Icon={MailIcon} - title={t("email.title")} - /> - - <HealthCard - badge={ - <Badge variant={WORKER_BADGE[summary.worker]}> - {t(`worker.${summary.worker}`)} - </Badge> - } - description={t(`worker.${summary.worker}_desc`)} - Icon={ClockIcon} - title={t("worker.title")} - > - {summary.failedQueueTasks > 0 ? ( - <p className="text-destructive text-sm leading-relaxed"> - {t("failed_queue", { count: summary.failedQueueTasks })} - </p> - ) : null} - </HealthCard> - - <HealthCard - description={ - oldestPendingEventAt ? t("oldest.desc") : t("oldest.none_desc") - } - Icon={HourglassIcon} - title={t("oldest.title")} - > - <p className="text-lg font-semibold"> - {oldestPendingEventAt ? ( - <DateFormat date={oldestPendingEventAt} /> - ) : ( - t("oldest.none") - )} - </p> - </HealthCard> - - <HealthCard - description={t("events.desc")} - Icon={ZapIcon} - title={t("events.title")} - > - <CountList - counts={summary.events} - label={status => tStatus(status)} - statuses={NOTIFICATION_EVENT_STATUSES} - /> - </HealthCard> - - <HealthCard - description={t("deliveries.desc")} - Icon={SendIcon} - title={t("deliveries.title")} - > - <CountList - counts={summary.deliveries} - label={status => tStatus(status)} - statuses={NOTIFICATION_DELIVERY_STATUSES} - /> - </HealthCard> - </div> - </section> - ); -}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/health.test.ts b/packages/vitnode/src/views/admin/views/core/system/notifications/health.test.ts deleted file mode 100644 index 391ae3e0f..000000000 --- a/packages/vitnode/src/views/admin/views/core/system/notifications/health.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { summarizeNotificationHealth } from "./health"; - -const overview = ({ - adapterConfigured = true, - cronActive = true, - cronStale = false, - deliveries = {}, - enabled = true, - events = {}, - queue = {}, -}: { - adapterConfigured?: boolean; - cronActive?: boolean; - cronStale?: boolean; - deliveries?: Record<string, number>; - enabled?: boolean; - events?: Record<string, number>; - queue?: Record<string, number>; -} = {}) => ({ - email: { adapterConfigured, enabled }, - health: { - cronActive, - cronStale, - deliveries, - events, - oldestPendingEventAt: null, - queue, - }, -}); - -describe("summarizeNotificationHealth", () => { - it("reports a healthy installation with no warnings", () => { - const summary = summarizeNotificationHealth(overview()); - - expect(summary.email).toBe("ready"); - expect(summary.worker).toBe("active"); - expect(summary.warnings).toEqual([]); - }); - - it("fills in zero for every status the API left out", () => { - const summary = summarizeNotificationHealth( - overview({ deliveries: { sent: 4 }, events: { completed: 2 } }), - ); - - expect(summary.events).toEqual({ - completed: 2, - failed: 0, - pending: 0, - processing: 0, - }); - expect(summary.deliveries).toEqual({ - failed: 0, - pending: 0, - sending: 0, - sent: 4, - skipped: 0, - }); - }); - - it("warns when cron is missing, before anything else", () => { - const summary = summarizeNotificationHealth( - overview({ - adapterConfigured: false, - cronActive: false, - cronStale: true, - }), - ); - - expect(summary.worker).toBe("inactive"); - expect(summary.warnings.map(warning => warning.kind)).toEqual([ - "cron_inactive", - "email_adapter_missing", - ]); - }); - - it("calls an active but quiet cron stale", () => { - const summary = summarizeNotificationHealth(overview({ cronStale: true })); - - expect(summary.worker).toBe("stale"); - expect(summary.warnings).toEqual([{ kind: "cron_stale" }]); - }); - - it("treats a configured adapter with email turned off as disabled, not broken", () => { - const summary = summarizeNotificationHealth(overview({ enabled: false })); - - expect(summary.email).toBe("disabled"); - expect(summary.warnings).toEqual([]); - }); - - it("counts failures with their totals", () => { - const summary = summarizeNotificationHealth( - overview({ deliveries: { failed: 3 }, events: { failed: 1 } }), - ); - - expect(summary.warnings).toEqual([ - { count: 1, kind: "failed_events" }, - { count: 3, kind: "failed_deliveries" }, - ]); - }); - - it("adds up failed notification queue tasks across task names", () => { - const summary = summarizeNotificationHealth( - overview({ - queue: { - "notifications-email:failed": 2, - "notifications-email:pending": 5, - "notifications-fanout:failed": 1, - }, - }), - ); - - expect(summary.failedQueueTasks).toBe(3); - }); -}); diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/health.ts b/packages/vitnode/src/views/admin/views/core/system/notifications/health.ts deleted file mode 100644 index 341939564..000000000 --- a/packages/vitnode/src/views/admin/views/core/system/notifications/health.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { AdminNotificationsOverview } from "./notifications-query"; -import type { - NotificationDeliveryStatus, - NotificationEventStatus, -} from "./statuses"; - -import { - NOTIFICATION_DELIVERY_STATUSES, - NOTIFICATION_EVENT_STATUSES, -} from "./statuses"; - -export type NotificationEmailHealth = "disabled" | "missing" | "ready"; -export type NotificationWorkerHealth = "active" | "inactive" | "stale"; - -export type NotificationHealthWarning = - | { count: number; kind: "failed_deliveries" } - | { count: number; kind: "failed_events" } - | { kind: "cron_inactive" } - | { kind: "cron_stale" } - | { kind: "email_adapter_missing" }; - -export interface NotificationHealthSummary { - deliveries: Record<NotificationDeliveryStatus, number>; - email: NotificationEmailHealth; - events: Record<NotificationEventStatus, number>; - failedQueueTasks: number; - warnings: NotificationHealthWarning[]; - worker: NotificationWorkerHealth; -} - -/** Every known status with a count - the API leaves out statuses with no rows. */ -const fillCounts = <S extends string>( - statuses: readonly S[], - counts: Record<string, number>, -): Record<S, number> => - Object.fromEntries( - statuses.map(status => [status, counts[status] ?? 0]), - ) as Record<S, number>; - -/** - * What the health cards and the warning banner show, derived from the - * overview's raw aggregates. Warnings are ordered by how much they block: - * nothing moves without cron, nothing is emailed without an adapter, and - * failures come last because they are already recoverable from this screen. - */ -export const summarizeNotificationHealth = ({ - email, - health, -}: Pick< - AdminNotificationsOverview, - "email" | "health" ->): NotificationHealthSummary => { - const events = fillCounts(NOTIFICATION_EVENT_STATUSES, health.events); - const deliveries = fillCounts( - NOTIFICATION_DELIVERY_STATUSES, - health.deliveries, - ); - const failedQueueTasks = Object.entries(health.queue) - .filter(([key]) => key.endsWith(":failed")) - .reduce((total, [, count]) => total + count, 0); - - const worker: NotificationWorkerHealth = !health.cronActive - ? "inactive" - : health.cronStale - ? "stale" - : "active"; - const emailHealth: NotificationEmailHealth = !email.adapterConfigured - ? "missing" - : email.enabled - ? "ready" - : "disabled"; - - const warnings: NotificationHealthWarning[] = []; - if (worker === "inactive") warnings.push({ kind: "cron_inactive" }); - if (worker === "stale") warnings.push({ kind: "cron_stale" }); - if (emailHealth === "missing") { - warnings.push({ kind: "email_adapter_missing" }); - } - if (events.failed > 0) { - warnings.push({ count: events.failed, kind: "failed_events" }); - } - if (deliveries.failed > 0) { - warnings.push({ count: deliveries.failed, kind: "failed_deliveries" }); - } - - return { - deliveries, - email: emailHealth, - events, - failedQueueTasks, - warnings, - worker, - }; -}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/maintenance-content.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/maintenance-content.tsx deleted file mode 100644 index 6917c93e4..000000000 --- a/packages/vitnode/src/views/admin/views/core/system/notifications/maintenance-content.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { - BrushCleaningIcon, - MailCheckIcon, - SearchCheckIcon, - WrenchIcon, -} 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 { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; - -import type { NotificationsAdminActions } from "./notifications-mutations"; - -export const NotificationsMaintenanceCard = ({ - actions, - canManage, - emailConfigured, - retentionDays, -}: { - actions: Pick< - NotificationsAdminActions, - "cleanup" | "reconcile" | "sendTestEmail" - >; - canManage: boolean; - emailConfigured: boolean; - retentionDays: number; -}) => { - const t = useTranslations("admin.system.notifications.maintenance"); - const tError = useTranslations("core.global.errors"); - const [mismatched, setMismatched] = React.useState<null | number>(null); - - const showError = () => { - toast.error(tError("title"), { - description: tError("internal_server_error"), - }); - }; - - const [, sendTestEmail, isSendingTestEmail] = React.useActionState( - async () => { - const mutation = await actions.sendTestEmail(); - - if (mutation.error !== undefined) { - if (mutation.status === 400) { - toast.error(tError("title"), { - description: t("test_email.not_configured"), - }); - } else showError(); - - return null; - } - - toast.success(t("test_email.success"), { - description: t("test_email.success_desc", { - id: mutation.data.deliveryId, - }), - }); - - return null; - }, - null, - ); - - const [, checkCounts, isChecking] = React.useActionState(async () => { - const mutation = await actions.reconcile({ dryRun: true }); - - if (mutation.error !== undefined) { - showError(); - - return null; - } - - setMismatched(mutation.data.mismatched); - - return null; - }, null); - - const [, cleanup, isCleaning] = React.useActionState(async () => { - const mutation = await actions.cleanup(); - - if (mutation.error !== undefined) { - showError(); - - return null; - } - - toast.success(t("cleanup.success"), { - description: t("cleanup.success_desc"), - }); - - return null; - }, null); - - return ( - <Card aria-labelledby="notifications-maintenance" role="region"> - <CardHeader> - <CardTitle> - <h2 - className="flex items-center gap-2 text-balance" - id="notifications-maintenance" - > - <WrenchIcon aria-hidden className="text-muted-foreground size-4" /> - {t("title")} - </h2> - </CardTitle> - <CardDescription className="leading-relaxed text-pretty"> - {canManage ? t("desc") : t("read_only")} - </CardDescription> - </CardHeader> - - {canManage ? ( - <CardContent className="flex flex-col gap-6"> - <div className="flex flex-col gap-2"> - <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> - {t("test_email.desc")} - </p> - <form action={sendTestEmail}> - <Button - disabled={!emailConfigured} - isLoading={isSendingTestEmail} - type="submit" - variant="outline" - > - <MailCheckIcon aria-hidden /> - {t("test_email.label")} - </Button> - </form> - {emailConfigured ? null : ( - <p className="text-muted-foreground text-sm leading-relaxed"> - {t("test_email.not_configured")} - </p> - )} - </div> - - <Separator /> - - <div className="flex flex-col gap-2"> - <h3 className="font-medium">{t("reconcile.title")}</h3> - <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> - {t("reconcile.desc")} - </p> - <div className="flex flex-wrap gap-2"> - <form action={checkCounts}> - <Button isLoading={isChecking} type="submit" variant="outline"> - <SearchCheckIcon aria-hidden /> - {t("reconcile.check")} - </Button> - </form> - <ConfirmActionAlertDialog - description={t("reconcile.confirm_desc")} - onSubmit={async ({ onClose }) => { - const mutation = await actions.reconcile({ dryRun: false }); - - if (mutation.error !== undefined) { - showError(); - - return; - } - - onClose(); - setMismatched(null); - toast.success(t("reconcile.fixed"), { - description: t("reconcile.fixed_desc", { - count: mutation.data.corrected, - }), - }); - }} - submitVariant="default" - textSubmit={t("reconcile.confirm_submit")} - title={t("reconcile.confirm_title")} - > - <Button variant="outline">{t("reconcile.fix")}</Button> - </ConfirmActionAlertDialog> - </div> - <p aria-live="polite" className="text-sm leading-relaxed"> - {mismatched === null - ? null - : mismatched === 0 - ? t("reconcile.result_ok") - : t("reconcile.result_mismatched", { count: mismatched })} - </p> - </div> - - <Separator /> - - <div className="flex flex-col gap-2"> - <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> - {t("retention", { days: retentionDays })} - </p> - <form action={cleanup}> - <Button isLoading={isCleaning} type="submit" variant="outline"> - <BrushCleaningIcon aria-hidden /> - {t("cleanup.label")} - </Button> - </form> - </div> - </CardContent> - ) : ( - <CardContent> - <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> - {t("retention", { days: retentionDays })} - </p> - </CardContent> - )} - </Card> - ); -}; 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 index 03961d821..b8939e2fc 100644 --- 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 @@ -1,12 +1,13 @@ +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 { NotificationsDeliveriesCard } from "./deliveries-content"; -import { summarizeNotificationHealth } from "./health"; -import { NotificationsHealthContent } from "./health-content"; -import { NotificationsMaintenanceCard } from "./maintenance-content"; -import { NotificationsSettingsCard } from "./settings-form"; -import { NotificationsTypesCard } from "./types-content"; +import { NotificationsActivity } from "./activity-content"; +import { NotificationsTypesSection } from "./types-content"; export interface NotificationsAdminPermissions { canEdit: boolean; @@ -22,38 +23,26 @@ export const NotificationsContent = ({ data: AdminNotificationsOverview; permissions: NotificationsAdminPermissions; }) => { - const summary = summarizeNotificationHealth(data); + const t = useTranslations("admin.system.notifications.paused"); return ( <div className="flex flex-col gap-8"> - <NotificationsHealthContent - oldestPendingEventAt={data.health.oldestPendingEventAt} - summary={summary} - /> + {data.settings.paused ? ( + <Alert variant="warning"> + <PauseIcon aria-hidden /> + <AlertTitle>{t("title")}</AlertTitle> + <AlertDescription>{t("desc")}</AlertDescription> + </Alert> + ) : null} - <div className="grid gap-6 xl:grid-cols-2"> - <NotificationsSettingsCard - canEdit={canEdit} - onSave={actions.updateSettings} - settings={data.settings} - /> - <NotificationsMaintenanceCard - actions={actions} - canManage={canManage} - emailConfigured={summary.email === "ready"} - retentionDays={data.settings.retentionDays} - /> - </div> - - <NotificationsTypesCard - canEdit={canEdit} - onUpdate={actions.updateTypePolicy} - types={data.types} - /> + <NotificationsActivity /> - <NotificationsDeliveriesCard + <NotificationsTypesSection + actions={actions} + canEdit={canEdit} canManage={canManage} - onRetry={actions.retryDelivery} + customizedMembers={data.counts.customizedMembers} + types={data.types} /> </div> ); 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 index 2bf9fa86b..3ff360fc0 100644 --- 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 @@ -3,7 +3,10 @@ import type { NotificationEmailMode } from "@/lib/notifications/types"; import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; -import type { AdminNotificationSettings } from "./notifications-query"; +import type { + AdminNotificationEditableSettings, + AdminNotificationSettings, +} from "./notifications-query"; /** What every write on this screen resolves to - never a throw. */ export type NotificationsMutationResult<T> = @@ -12,27 +15,38 @@ export type NotificationsMutationResult<T> = export interface NotificationTypePolicyPatch { allowEmail?: boolean; + allowInApp?: boolean; + allowPush?: boolean; email?: NotificationEmailMode; enabled?: boolean; inApp?: boolean; + memberCanEdit?: boolean; } /** The writes the screen can perform, as the host hands them in. */ export interface NotificationsAdminActions { + cancelQueuedEmails: () => Promise< + NotificationsMutationResult<{ cancelled: number }> + >; cleanup: () => Promise<NotificationsMutationResult<{ success: boolean }>>; - reconcile: (body: { - dryRun: boolean; - }) => Promise< - NotificationsMutationResult<{ corrected: number; mismatched: number }> + deleteAll: () => Promise< + NotificationsMutationResult<{ events: number; items: number }> + >; + markEverythingRead: () => Promise< + NotificationsMutationResult<{ items: number; members: number }> + >; + pause: () => Promise<NotificationsMutationResult<{ success: boolean }>>; + resetMemberPreferences: () => Promise< + NotificationsMutationResult<{ members: number }> + >; + resume: () => Promise< + NotificationsMutationResult<{ requeuedEvents: number }> >; - retryDelivery: ( - id: number, - ) => Promise<NotificationsMutationResult<{ success: boolean }>>; sendTestEmail: () => Promise< NotificationsMutationResult<{ deliveryId: number }> >; updateSettings: ( - body: Partial<AdminNotificationSettings>, + body: Partial<AdminNotificationEditableSettings>, ) => Promise<NotificationsMutationResult<AdminNotificationSettings>>; updateTypePolicy: ( type: string, @@ -47,16 +61,34 @@ const failure = async (response: { text: () => Promise<string>; }) => ({ error: await response.text(), status: response.status }); +const options = { credentials: "include" } as const; + // Each call catches: the client throws on a 500 with the server's own text, // which the server has already logged. The screen only needs to know it failed. export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { + cancelQueuedEmails: async () => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/notifications", + options, + path: "/emails/cancel", + }); + if (!response.ok) return await failure(response); + + return { data: await response.json() }; + } catch { + return FAILED; + } + }, cleanup: async () => { try { const response = await fetcherClient({ plugin: CONFIG_PLUGIN.pluginId, method: "post", module: "admin/notifications", - options: { credentials: "include" }, + options, path: "/cleanup", }); if (!response.ok) return await failure(response); @@ -66,15 +98,62 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { return FAILED; } }, - reconcile: async body => { + deleteAll: async () => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/notifications", + options, + 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", + options, + 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", + options, + 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, - args: { body }, method: "post", module: "admin/notifications", - options: { credentials: "include" }, - path: "/reconcile", + options, + path: "/members/reset-preferences", }); if (!response.ok) return await failure(response); @@ -83,15 +162,14 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { return FAILED; } }, - retryDelivery: async id => { + resume: async () => { try { const response = await fetcherClient({ plugin: CONFIG_PLUGIN.pluginId, - args: { params: { id: id } }, method: "post", module: "admin/notifications", - options: { credentials: "include" }, - path: "/deliveries/{id}/retry", + options, + path: "/resume", }); if (!response.ok) return await failure(response); @@ -106,7 +184,7 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { plugin: CONFIG_PLUGIN.pluginId, method: "post", module: "admin/notifications", - options: { credentials: "include" }, + options, path: "/test-email", }); if (!response.ok) return await failure(response); @@ -123,7 +201,7 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { args: { body }, method: "put", module: "admin/notifications", - options: { credentials: "include" }, + options, path: "/settings", }); if (!response.ok) return await failure(response); @@ -140,7 +218,7 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { args: { body, params: { type } }, method: "put", module: "admin/notifications", - options: { credentials: "include" }, + options, path: "/types/{type}", }); if (!response.ok) return await failure(response); 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 index 3935f3197..82781a7fe 100644 --- 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 @@ -1,4 +1,4 @@ -import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import { queryOptions } from "@tanstack/react-query"; import type { NotificationEmailMode } from "@/lib/notifications/types"; @@ -8,24 +8,34 @@ import { fetcher } from "@/tanstack/fetcher"; import { AdminRequestError } from "@/views/admin/admin-request"; import { adminQueryRoot } from "@/views/admin/table/query"; -import type { NotificationDeliveryStatus } from "./statuses"; - -export type NotificationDeliveryMode = - "daily" | "immediate" | "test" | "weekly"; - export interface AdminNotificationSettings { + digestHour: number; + digestWeekday: number; + emailCapPerHour: number; + emailEnabled: boolean; + paused: boolean; +} + +export interface AdminNotificationWorkers { emailBatchSize: number; emailConcurrency: number; - emailEnabled: boolean; fanoutBatchSize: number; retentionDays: number; } +export type AdminNotificationEditableSettings = Omit< + AdminNotificationSettings, + "paused" +>; + export interface AdminNotificationTypePolicy { allowEmail: boolean; + allowInApp: boolean; + allowPush: boolean; email: NotificationEmailMode | null; enabled: boolean; inApp: boolean | null; + memberCanEdit: boolean; } export interface AdminNotificationType { @@ -43,6 +53,12 @@ export interface AdminNotificationType { } export interface AdminNotificationsOverview { + counts: { + customizedMembers: number; + inboxItems: number; + queuedEmails: number; + unreadItems: number; + }; email: { adapterConfigured: boolean; enabled: boolean }; health: { cronActive: boolean; @@ -54,32 +70,27 @@ export interface AdminNotificationsOverview { }; settings: AdminNotificationSettings; types: AdminNotificationType[]; + workers: AdminNotificationWorkers; } -export interface AdminNotificationDelivery { - attempts: number; - availableAt: Date | string; - createdAt: Date | string; - id: number; - itemCount: number; - lastError: null | string; - maxAttempts: number; - mode: NotificationDeliveryMode; - providerMessageId: null | string; - sentAt: Date | null | string; - skipReason: null | string; - status: NotificationDeliveryStatus; - type: null | string; - updatedAt: Date | string; - userId: number; -} +export const NOTIFICATION_STATS_RANGES = ["24h", "7d", "30d"] as const; +export type NotificationStatsRange = (typeof NOTIFICATION_STATS_RANGES)[number]; -export interface AdminNotificationDeliveriesPage { - items: AdminNotificationDelivery[]; - nextCursor: null | number; +export interface NotificationStatsTotals { + events: number; + failed: number; + sent: number; + skipped: number; } -export const DELIVERIES_PAGE_SIZE = 25; +export interface AdminNotificationStats { + points: (NotificationStatsTotals & { key: string })[]; + previous: NotificationStatsTotals; + range: NotificationStatsRange; + timeZone: string; + totals: NotificationStatsTotals; + unit: "day" | "hour"; +} export const fetchNotificationsOverview = async (): Promise<AdminNotificationsOverview> => { @@ -100,36 +111,23 @@ export const fetchNotificationsOverview = return await response.json(); }; -export const fetchNotificationDeliveries = async ({ - cursor, - signal, - status, +export const fetchNotificationStats = async ({ + range, + timeZone, }: { - cursor: null | number; - signal?: AbortSignal; - status?: NotificationDeliveryStatus; -}): Promise<AdminNotificationDeliveriesPage> => { + range: NotificationStatsRange; + timeZone: string; +}): Promise<AdminNotificationStats> => { const response = await fetcher({ plugin: CONFIG_PLUGIN.pluginId, - args: { - query: { - cursor: cursor ?? undefined, - limit: DELIVERIES_PAGE_SIZE, - status, - }, - }, + args: { query: { range, timeZone } }, method: "get", module: "admin/notifications", - options: { signal }, - path: "/deliveries", + path: "/stats", }); if (!response.ok) { - throw new AdminRequestError( - response.status, - "the notification deliveries", - status ? `status=${status}` : undefined, - ); + throw new AdminRequestError(response.status, "the notification activity"); } return await response.json(); @@ -143,9 +141,9 @@ export const notificationsOverviewQueryKey = [ "overview", ] as const; -export const notificationDeliveriesQueryRoot = [ +export const notificationStatsQueryRoot = [ ...notificationsAdminQueryRoot, - "deliveries", + "stats", ] as const; export const notificationsOverviewQueryOptions = () => @@ -157,18 +155,16 @@ export const notificationsOverviewQueryOptions = () => staleTime: OPERATIONAL_STALE_TIME, }); -export const notificationDeliveriesQueryOptions = ({ - status, +export const notificationStatsQueryOptions = ({ + range, + timeZone, }: { - status?: NotificationDeliveryStatus; + range: NotificationStatsRange; + timeZone: string; }) => - infiniteQueryOptions({ - getNextPageParam: (page: AdminNotificationDeliveriesPage) => - page.nextCursor ?? undefined, - initialPageParam: null as null | number, - queryFn: async ({ pageParam, signal }) => - await fetchNotificationDeliveries({ cursor: pageParam, signal, status }), - queryKey: [...notificationDeliveriesQueryRoot, status ?? "all"] as const, + 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 ( + <ConfirmActionAlertDialog + description={ + <span className="flex flex-col gap-2 leading-relaxed text-pretty"> + <span>{t("desc", { count })}</span> + <span className="text-foreground font-medium">{t("warning")}</span> + </span> + } + 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")} + > + <Button className="shrink-0 self-start" variant="outline"> + <RotateCcwIcon aria-hidden /> + {t("button")} + </Button> + </ConfirmActionAlertDialog> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/settings-form.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/settings-form.tsx deleted file mode 100644 index 4db4d28d0..000000000 --- a/packages/vitnode/src/views/admin/views/core/system/notifications/settings-form.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { toast } from "sonner"; -import { useTranslations } from "use-intl"; -import { z } from "zod"; - -import type { - AutoFormOnSubmit, - ItemAutoFormComponentProps, -} from "@/components/form/auto-form"; - -import { AutoForm } from "@/components/form/auto-form"; -import { AutoFormNumber } from "@/components/form/fields/number"; -import { AutoFormSwitch } from "@/components/form/fields/switch"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; - -import type { NotificationsAdminActions } from "./notifications-mutations"; -import type { AdminNotificationSettings } from "./notifications-query"; - -const NUMBER_FIELDS = [ - { id: "retentionDays", max: 3650, min: 1 }, - { id: "fanoutBatchSize", max: 5000, min: 10 }, - { id: "emailBatchSize", max: 500, min: 1 }, - { id: "emailConcurrency", max: 20, min: 1 }, -] as const; - -const SettingsReadOnly = ({ - settings, -}: { - settings: AdminNotificationSettings; -}) => { - const t = useTranslations("admin.system.notifications.settings"); - - return ( - <div className="flex flex-col gap-4"> - <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> - {t("read_only")} - </p> - <dl className="grid gap-4 sm:grid-cols-2"> - <div className="flex flex-col gap-1"> - <dt className="text-muted-foreground text-sm"> - {t("emailEnabled.label")} - </dt> - <dd className="font-medium"> - {settings.emailEnabled ? t("enabled") : t("disabled")} - </dd> - </div> - {NUMBER_FIELDS.map(({ id }) => ( - <div className="flex flex-col gap-1" key={id}> - <dt className="text-muted-foreground text-sm"> - {t(`${id}.label`)} - </dt> - <dd className="font-medium tabular-nums"> - {settings[id].toLocaleString()} - {id === "retentionDays" ? ` ${t("retentionDays.unit")}` : null} - </dd> - </div> - ))} - </dl> - </div> - ); -}; - -const SettingsForm = ({ - onSave, - settings, -}: { - onSave: NotificationsAdminActions["updateSettings"]; - settings: AdminNotificationSettings; -}) => { - const t = useTranslations("admin.system.notifications.settings"); - const tError = useTranslations("core.global.errors"); - - const formSchema = z.object({ - emailEnabled: z.boolean().default(settings.emailEnabled), - retentionDays: z - .number() - .int() - .min(1) - .max(3650) - .default(settings.retentionDays), - fanoutBatchSize: z - .number() - .int() - .min(10) - .max(5000) - .default(settings.fanoutBatchSize), - emailBatchSize: z - .number() - .int() - .min(1) - .max(500) - .default(settings.emailBatchSize), - emailConcurrency: z - .number() - .int() - .min(1) - .max(20) - .default(settings.emailConcurrency), - }); - - const onSubmit: AutoFormOnSubmit<typeof formSchema> = async values => { - const mutation = await onSave(values); - - if (mutation.error !== undefined) { - toast.error(tError("title"), { - description: tError("internal_server_error"), - }); - - return; - } - - toast.success(t("success"), { description: t("success_desc") }); - }; - - return ( - <AutoForm - fields={[ - { - id: "emailEnabled", - component: props => ( - <AutoFormSwitch - {...props} - description={t("emailEnabled.desc")} - label={t("emailEnabled.label")} - /> - ), - }, - ...NUMBER_FIELDS.map(({ id, max, min }) => ({ - id, - component: (props: ItemAutoFormComponentProps) => ( - <AutoFormNumber - {...props} - description={t(`${id}.desc`)} - label={t(`${id}.label`)} - max={max} - min={min} - step={1} - unitLabel={ - id === "retentionDays" ? t("retentionDays.unit") : undefined - } - /> - ), - })), - ]} - formSchema={formSchema} - onSubmit={onSubmit} - submitButtonProps={{ children: t("submit") }} - /> - ); -}; - -export const NotificationsSettingsCard = ({ - canEdit, - onSave, - settings, -}: { - canEdit: boolean; - onSave: NotificationsAdminActions["updateSettings"]; - settings: AdminNotificationSettings; -}) => { - const t = useTranslations("admin.system.notifications.settings"); - - return ( - <Card aria-labelledby="notifications-settings" role="region"> - <CardHeader> - <CardTitle> - <h2 className="text-balance" id="notifications-settings"> - {t("title")} - </h2> - </CardTitle> - <CardDescription className="leading-relaxed text-pretty"> - {t("desc")} - </CardDescription> - </CardHeader> - <CardContent> - {canEdit ? ( - <SettingsForm onSave={onSave} settings={settings} /> - ) : ( - <SettingsReadOnly settings={settings} /> - )} - </CardContent> - </Card> - ); -}; 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..f07e9175d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/settings-sheet-content.tsx @@ -0,0 +1,736 @@ +import { cn } from "cn"; +import { + BrushCleaningIcon, + ChevronRightIcon, + type LucideIcon, + MailCheckIcon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useFormatter, useTranslations } from "use-intl"; +import { z } from "zod"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormNumber } from "@/components/form/fields/number"; +import { AutoFormSelect } from "@/components/form/fields/select"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; + +import type { NotificationsAdminActions } from "./notifications-mutations"; +import type { + AdminNotificationEditableSettings, + AdminNotificationsOverview, +} from "./notifications-query"; + +import { DeleteAllDialog } from "./delete-all-dialog"; + +export interface NotificationsSettingsSheetBodyProps { + actions: NotificationsAdminActions; + canEdit: boolean; + canManage: boolean; + counts: AdminNotificationsOverview["counts"]; + emailConfigured: boolean; + settings: AdminNotificationsOverview["settings"]; + workers: AdminNotificationsOverview["workers"]; +} + +type NumberKey = "emailCapPerHour"; + +const LIMITS = { + emailCapPerHour: [0, 500], +} as const satisfies Record<NumberKey, readonly [number, number]>; + +const HOURS = Array.from({ length: 24 }, (_, hour) => String(hour)); +const WEEKDAYS = ["0", "1", "2", "3", "4", "5", "6"]; + +const Group = ({ + children, + title, + tone, +}: { + children: React.ReactNode; + title: string; + tone?: "danger"; +}) => { + const id = React.useId(); + + return ( + <section aria-labelledby={id} className="flex flex-col gap-2"> + <h3 + className={cn( + "px-1 text-xs font-medium", + tone === "danger" ? "text-destructive" : "text-muted-foreground", + )} + id={id} + > + {title} + </h3> + <ul + className={cn( + "bg-card divide-y overflow-hidden rounded-xl border", + tone === "danger" && "border-destructive/40 divide-destructive/20", + )} + > + {children} + </ul> + </section> + ); +}; + +const RowText = ({ + description, + label, + tone, +}: { + description?: string; + label: string; + tone?: "danger"; +}) => ( + <span className="flex min-w-0 flex-1 flex-col gap-0.5"> + <span + className={cn( + "text-sm font-medium", + tone === "danger" && "text-destructive", + )} + > + {label} + </span> + {description ? ( + <span className="text-muted-foreground text-xs leading-relaxed text-pretty"> + {description} + </span> + ) : null} + </span> +); + +const rowButtonClass = + "focus-visible:ring-ring/50 flex min-h-12 w-full items-center gap-3 px-4 py-3 text-start outline-none focus-visible:ring-[3px] focus-visible:ring-inset [@media(hover:hover)]:hover:bg-muted/50"; + +const EditableRow = ({ + canEdit, + children, + isOpen, + label, + onToggle, + value, +}: { + canEdit: boolean; + children: React.ReactNode; + isOpen: boolean; + label: string; + onToggle: () => void; + value: string; +}) => { + const t = useTranslations("admin.system.notifications.settings"); + const panelId = React.useId(); + + if (!canEdit) { + return ( + <li className="flex min-h-12 items-center gap-3 px-4 py-3"> + <RowText label={label} /> + <span className="text-muted-foreground shrink-0 text-sm tabular-nums"> + {value} + </span> + </li> + ); + } + + return ( + <li> + <button + aria-controls={panelId} + aria-expanded={isOpen} + aria-label={`${t("edit", { label })}: ${value}`} + className={rowButtonClass} + onClick={onToggle} + type="button" + > + <RowText label={label} /> + <span className="text-muted-foreground shrink-0 text-sm tabular-nums"> + {value} + </span> + <ChevronRightIcon + aria-hidden + className={cn( + "text-muted-foreground size-4 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none", + isOpen && "rotate-90", + )} + /> + </button> + {isOpen ? ( + <div className="bg-muted/30 border-t px-4 py-4" id={panelId}> + {children} + </div> + ) : null} + </li> + ); +}; + +const EditorFooter = ({ + isSaving, + onCancel, +}: { + isSaving: boolean; + onCancel: () => void; +}) => { + const t = useTranslations("admin.system.notifications.settings"); + + return ( + <div className="flex justify-end gap-2"> + <Button onClick={onCancel} type="button" variant="ghost"> + {t("cancel")} + </Button> + <Button isLoading={isSaving} type="submit"> + {t("save")} + </Button> + </div> + ); +}; + +const useSettingSaver = ({ + actions, + onSaved, +}: { + actions: NotificationsAdminActions; + onSaved: () => void; +}) => { + const t = useTranslations("admin.system.notifications.settings"); + const tError = useTranslations("core.global.errors"); + const [isSaving, setIsSaving] = React.useState(false); + + const save = async ( + patch: Partial<AdminNotificationEditableSettings>, + label: string, + value: string, + ) => { + setIsSaving(true); + const mutation = await actions.updateSettings(patch); + setIsSaving(false); + + if (mutation.error !== undefined) { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + + return; + } + + onSaved(); + toast.success(t("success"), { + description: t("success_desc", { label, value }), + }); + }; + + return { isSaving, save }; +}; + +const NumberEditor = ({ + actions, + display, + field, + onDone, + saved, +}: { + actions: NotificationsAdminActions; + display: (value: number) => string; + field: NumberKey; + onDone: () => void; + saved: number; +}) => { + const t = useTranslations("admin.system.notifications.settings"); + const [min, max] = LIMITS[field]; + const { isSaving, save } = useSettingSaver({ actions, onSaved: onDone }); + const formSchema = z.object({ + [field]: z.number().int().min(min).max(max).default(saved), + }); + + return ( + <AutoForm + fields={[ + { + id: field, + component: props => ( + <AutoFormNumber + {...props} + autoFocus + className="w-full sm:w-48" + description={t(`${field}.desc`)} + label={t(`${field}.label`)} + max={max} + min={min} + step={1} + /> + ), + }, + ]} + formSchema={formSchema} + layout={rendered => ( + <div className="flex flex-col gap-4"> + {rendered[field]} + <EditorFooter isSaving={isSaving} onCancel={onDone} /> + </div> + )} + onSubmit={async values => { + const value = values[field]; + await save({ [field]: value }, t(`${field}.label`), display(value)); + }} + /> + ); +}; + +const SelectEditor = ({ + actions, + field, + labels, + onDone, + saved, +}: { + actions: NotificationsAdminActions; + field: "digestHour" | "digestWeekday"; + labels: { label: string; value: string }[]; + onDone: () => void; + saved: number; +}) => { + const t = useTranslations("admin.system.notifications.settings"); + const { isSaving, save } = useSettingSaver({ actions, onSaved: onDone }); + const values = labels.map(item => item.value) as [string, ...string[]]; + const formSchema = z.object({ + [field]: z.enum(values).default(String(saved)), + }); + + return ( + <AutoForm + fields={[ + { + id: field, + component: props => ( + <AutoFormSelect + {...props} + description={t(`${field}.desc`)} + label={t(`${field}.label`)} + labels={labels} + /> + ), + }, + ]} + formSchema={formSchema} + layout={rendered => ( + <div className="flex flex-col gap-4"> + {rendered[field]} + <EditorFooter isSaving={isSaving} onCancel={onDone} /> + </div> + )} + onSubmit={async submitted => { + const value = Number(submitted[field]); + await save( + { [field]: value }, + t(`${field}.label`), + labels.find(item => item.value === String(value))?.label ?? + String(value), + ); + }} + /> + ); +}; + +const ActionRow = ({ + description, + Icon, + label, + onRun, +}: { + description: string; + Icon: LucideIcon; + label: string; + onRun: () => Promise<void>; +}) => { + const t = useTranslations("admin.system.notifications.tools"); + const [isRunning, setIsRunning] = React.useState(false); + + return ( + <li className="flex min-h-12 items-center gap-3 px-4 py-3"> + <Icon aria-hidden className="text-muted-foreground size-4 shrink-0" /> + <RowText description={description} label={label} /> + <Button + aria-label={t("run_label", { label })} + className="shrink-0" + isLoading={isRunning} + onClick={() => { + setIsRunning(true); + void onRun().finally(() => { + setIsRunning(false); + }); + }} + size="sm" + variant="outline" + > + {t("run")} + </Button> + </li> + ); +}; + +const DangerRow = ({ + confirm, + description, + title, + tone = "danger", + trailing, +}: { + confirm: (trigger: React.ReactElement) => React.ReactNode; + description: string; + title: string; + tone?: "danger" | "safe"; + trailing?: React.ReactNode; +}) => ( + <li> + {confirm( + <button className={rowButtonClass} type="button"> + <RowText + description={description} + label={title} + tone={tone === "danger" ? "danger" : undefined} + /> + {trailing} + <ChevronRightIcon + aria-hidden + className="text-muted-foreground size-4 shrink-0" + /> + </button>, + )} + </li> +); + +export const NotificationsSettingsSheetBody = ({ + actions, + canEdit, + canManage, + 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 format = useFormatter(); + const [openKey, setOpenKey] = React.useState<null | string>(null); + const emailSwitchId = React.useId(); + const [emailEnabled, setEmailEnabled] = React.useState(settings.emailEnabled); + + const toggle = (key: string) => () => { + setOpenKey(current => (current === key ? null : key)); + }; + const close = () => { + setOpenKey(null); + }; + + const hourLabels = HOURS.map(hour => ({ + label: format.dateTime(new Date(Date.UTC(2024, 0, 1, Number(hour))), { + hour: "2-digit", + minute: "2-digit", + timeZone: "UTC", + }), + value: hour, + })); + const weekdayLabels = WEEKDAYS.map(day => ({ + label: format.dateTime(new Date(Date.UTC(2024, 0, 7 + Number(day))), { + timeZone: "UTC", + weekday: "long", + }), + value: day, + })); + + const display = { + emailCapPerHour: (value: number) => + value === 0 ? t("emailCapPerHour.none") : format.number(value), + } satisfies Record<NumberKey, (value: number) => string>; + + const failed = () => { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + }; + + const numberRow = (field: NumberKey) => ( + <EditableRow + canEdit={canEdit} + isOpen={openKey === field} + key={field} + label={t(`${field}.label`)} + onToggle={toggle(field)} + value={display[field](settings[field])} + > + <NumberEditor + actions={actions} + display={display[field]} + field={field} + onDone={close} + saved={settings[field]} + /> + </EditableRow> + ); + + return ( + <div className="flex flex-col gap-6 px-4 py-5"> + <Group title={t("groups.email")}> + <li className="flex min-h-12 items-center justify-between gap-3 px-4 py-3"> + <label className="flex flex-col gap-0.5" htmlFor={emailSwitchId}> + <span className="text-sm font-medium"> + {t("emailEnabled.label")} + </span> + <span className="text-muted-foreground text-xs leading-relaxed text-pretty"> + {t("emailEnabled.desc")} + </span> + </label> + <Switch + checked={emailEnabled} + disabled={!canEdit} + id={emailSwitchId} + onCheckedChange={checked => { + setEmailEnabled(checked); + void actions + .updateSettings({ emailEnabled: checked }) + .then(mutation => { + if (mutation.error !== undefined) { + setEmailEnabled(!checked); + failed(); + + return; + } + toast.success(t("success"), { + description: t("success_desc", { + label: t("emailEnabled.label"), + value: checked ? t("on") : t("off"), + }), + }); + }); + }} + /> + </li> + {numberRow("emailCapPerHour")} + <EditableRow + canEdit={canEdit} + isOpen={openKey === "digestHour"} + label={t("digestHour.label")} + onToggle={toggle("digestHour")} + value={hourLabels[settings.digestHour]?.label ?? ""} + > + <SelectEditor + actions={actions} + field="digestHour" + labels={hourLabels} + onDone={close} + saved={settings.digestHour} + /> + </EditableRow> + <EditableRow + canEdit={canEdit} + isOpen={openKey === "digestWeekday"} + label={t("digestWeekday.label")} + onToggle={toggle("digestWeekday")} + value={weekdayLabels[settings.digestWeekday]?.label ?? ""} + > + <SelectEditor + actions={actions} + field="digestWeekday" + labels={weekdayLabels} + onDone={close} + saved={settings.digestWeekday} + /> + </EditableRow> + </Group> + + {canManage ? ( + <> + <Group title={t("groups.tools")}> + {emailConfigured ? ( + <ActionRow + description={tTools("test_email.desc")} + Icon={MailCheckIcon} + label={tTools("test_email.label")} + onRun={async () => { + 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} + <ActionRow + description={tTools("cleanup.desc", { + count: workers.retentionDays, + })} + Icon={BrushCleaningIcon} + label={tTools("cleanup.label")} + onRun={async () => { + const mutation = await actions.cleanup(); + if (mutation.error !== undefined) { + failed(); + + return; + } + toast.success(tTools("cleanup.success"), { + description: tTools("cleanup.success_desc"), + }); + }} + /> + </Group> + + <Group title={t("groups.danger")} tone="danger"> + <li className="text-muted-foreground px-4 py-3 text-xs leading-relaxed"> + {tDanger("intro")} + </li> + {settings.paused ? ( + <DangerRow + confirm={trigger => ( + <ConfirmActionAlertDialog + description={tDanger("resume.confirm_desc")} + onSubmit={async ({ onClose }) => { + 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} + </ConfirmActionAlertDialog> + )} + description={tDanger("resume.desc")} + title={tDanger("resume.title")} + tone="safe" + trailing={ + <Badge variant="warning">{tDanger("paused_badge")}</Badge> + } + /> + ) : ( + <DangerRow + confirm={trigger => ( + <ConfirmActionAlertDialog + description={tDanger("pause.confirm_desc")} + onSubmit={async ({ onClose }) => { + 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} + </ConfirmActionAlertDialog> + )} + description={tDanger("pause.desc")} + title={tDanger("pause.title")} + /> + )} + <DangerRow + confirm={trigger => ( + <ConfirmActionAlertDialog + description={tDanger("cancel_emails.confirm_desc", { + count: counts.queuedEmails, + })} + onSubmit={async ({ onClose }) => { + 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} + </ConfirmActionAlertDialog> + )} + description={tDanger("cancel_emails.desc")} + title={tDanger("cancel_emails.title")} + /> + <DangerRow + confirm={trigger => ( + <ConfirmActionAlertDialog + description={tDanger("read_all.confirm_desc", { + count: counts.unreadItems, + })} + onSubmit={async ({ onClose }) => { + 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} + </ConfirmActionAlertDialog> + )} + description={tDanger("read_all.desc")} + title={tDanger("read_all.title")} + /> + <DangerRow + confirm={trigger => ( + <DeleteAllDialog + count={counts.inboxItems} + onDelete={actions.deleteAll} + trigger={trigger} + /> + )} + description={tDanger("delete_all.desc")} + title={tDanger("delete_all.title")} + /> + </Group> + </> + ) : null} + + <p className="text-muted-foreground px-1 text-xs leading-relaxed text-pretty"> + {t("config_note", { days: workers.retentionDays })} + </p> + </div> + ); +}; 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..4430b3100 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/settings-sheet.tsx @@ -0,0 +1,68 @@ +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 = () => ( + <div aria-hidden className="flex flex-col gap-6 px-4 py-5"> + {["h-48", "h-44", "h-24", "h-60"].map(height => ( + <div className="flex flex-col gap-2" key={height}> + <Skeleton className="h-3 w-24" /> + <Skeleton className={`w-full rounded-xl ${height}`} /> + </div> + ))} + </div> +); + +export const NotificationsSettingsSheet = ( + props: NotificationsSettingsSheetBodyProps, +) => { + const t = useTranslations("admin.system.notifications.settings"); + const [open, setOpen] = React.useState(false); + + return ( + <> + <Button + onClick={() => { + setOpen(true); + }} + variant="outline" + > + <Settings2Icon aria-hidden /> + {t("button")} + </Button> + <Sheet onOpenChange={setOpen} open={open}> + <SheetContent className="w-full gap-0 data-[side=right]:w-full data-[side=right]:sm:max-w-md"> + <SheetHeader className="border-b"> + <SheetTitle>{t("title")}</SheetTitle> + <SheetDescription> + {props.canEdit ? t("desc") : t("read_only")} + </SheetDescription> + </SheetHeader> + <div className="bg-muted/30 flex-1 overflow-y-auto"> + <React.Suspense fallback={<SheetBodySkeleton />}> + <NotificationsSettingsSheetBody {...props} /> + </React.Suspense> + </div> + </SheetContent> + </Sheet> + </> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/statuses.ts b/packages/vitnode/src/views/admin/views/core/system/notifications/statuses.ts deleted file mode 100644 index 5ef201ee3..000000000 --- a/packages/vitnode/src/views/admin/views/core/system/notifications/statuses.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const NOTIFICATION_EVENT_STATUSES = [ - "pending", - "processing", - "completed", - "failed", -] as const; -export type NotificationEventStatus = - (typeof NOTIFICATION_EVENT_STATUSES)[number]; - -export const NOTIFICATION_DELIVERY_STATUSES = [ - "failed", - "pending", - "sending", - "sent", - "skipped", -] as const; -export type NotificationDeliveryStatus = - (typeof NOTIFICATION_DELIVERY_STATUSES)[number]; 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<AdminNotificationType> = {}, + policy: Partial<AdminNotificationType["policy"]> = {}, +): 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 index 04d401505..bf20e5d1e 100644 --- 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 @@ -1,16 +1,20 @@ -import { BellOffIcon } from "lucide-react"; +import { cn } from "cn"; +import { + BellOffIcon, + ChevronDownIcon, + ListIcon, + LockIcon, + type LucideIcon, + MailIcon, + SmartphoneIcon, +} from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import React from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; import { Badge } from "@/components/ui/badge"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card } from "@/components/ui/card"; import { Empty, EmptyDescription, @@ -19,78 +23,140 @@ import { EmptyTitle, } from "@/components/ui/empty"; import { Label } from "@/components/ui/label"; -import { - NativeSelect, - NativeSelectOption, -} from "@/components/ui/native-select"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Switch } from "@/components/ui/switch"; -import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types"; +import { + REVEAL_EXIT_TRANSITION, + REVEAL_HIDDEN, + REVEAL_SHOWN, + REVEAL_TRANSITION, +} from "@/lib/motion"; import type { NotificationsAdminActions, NotificationTypePolicyPatch, } from "./notifications-mutations"; -import type { - AdminNotificationType, - AdminNotificationTypePolicy, -} from "./notifications-query"; +import type { AdminNotificationType } from "./notifications-query"; +import type { TypeChoices } from "./type-policy"; -const isEmailMode = ( - value: string, -): value is (typeof NOTIFICATION_EMAIL_MODES)[number] => - (NOTIFICATION_EMAIL_MODES as readonly string[]).includes(value); +import { ResetMembersButton } from "./reset-members"; +import { + applyPatch, + choicesOf, + EMAIL_CHOICES, + emailPatch, + isSentNowhere, + LIST_CHOICES, + listPatch, + PUSH_CHOICES, + pushPatch, +} from "./type-policy"; -const SwitchField = ({ - checked, - disabled, - hint, +const SummaryChip = ({ + Icon, label, - onCheckedChange, + muted, + value, }: { - checked: boolean; - disabled: boolean; - hint?: string; + Icon: LucideIcon; label: string; - onCheckedChange: (checked: boolean) => void; + muted: boolean; + value: string; }) => ( - <div className="flex flex-col gap-1"> - <Label className="font-normal"> - <Switch - checked={checked} + <li + className={cn( + "flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs whitespace-nowrap", + muted ? "text-muted-foreground border-dashed" : "bg-card text-foreground", + )} + > + <Icon aria-hidden className="size-3.5 shrink-0" /> + <span className="sr-only">{label}:</span> + {value} + </li> +); + +const ChoiceGroup = <V extends string>({ + 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 ( + <fieldset className="flex min-w-0 flex-col" disabled={disabled}> + <legend className="mb-3 flex items-center gap-2 text-sm font-medium"> + <Icon aria-hidden className="text-muted-foreground size-4" /> + {legend} + </legend> + <RadioGroup + className="gap-2" disabled={disabled} - onCheckedChange={value => { - onCheckedChange(value); + onValueChange={next => { + const picked = options.find(option => option.value === next); + if (picked) onChange(picked.value); }} - size="sm" - /> - {label} - </Label> - {hint ? ( - <p className="text-muted-foreground text-xs leading-relaxed">{hint}</p> - ) : null} - </div> -); + value={value} + > + {options.map(option => ( + <Label + className={cn( + "bg-card flex cursor-pointer items-start gap-3 rounded-lg border p-3 font-normal transition-[border-color,box-shadow] duration-150 ease-out", + "has-data-checked:border-primary has-data-checked:ring-primary/15 has-disabled:cursor-not-allowed has-disabled:opacity-70 has-data-checked:ring-3", + "[@media(hover:hover)]:hover:border-foreground/20", + )} + key={option.value} + > + <RadioGroupItem className="mt-0.5" value={option.value} /> + <span className="flex flex-col gap-0.5"> + <span className="text-sm font-medium">{option.label}</span> + <span className="text-muted-foreground text-xs leading-relaxed"> + {option.hint} + </span> + </span> + </Label> + ))} + </RadioGroup> + {note ? ( + <p className="text-muted-foreground mt-2 text-xs leading-relaxed text-pretty"> + {note} + </p> + ) : null} + </fieldset> + ); +}; -const TypeRow = ({ +const TypeItem = ({ canEdit, + isOpen, + onToggle, onUpdate, - type, + 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 selectId = React.useId(); - const [isPending, startTransition] = React.useTransition(); - const [policy, applyOptimistic] = React.useOptimistic( - type.policy, - ( - current: AdminNotificationTypePolicy, - patch: NotificationTypePolicyPatch, - ): AdminNotificationTypePolicy => ({ ...current, ...patch }), - ); + const panelId = React.useId(); + const memberId = React.useId(); + const shouldReduceMotion = useReducedMotion(); + const [, startTransition] = React.useTransition(); + const [type, applyOptimistic] = React.useOptimistic(initial, applyPatch); + const choices: TypeChoices = choicesOf(type); + const locked = !canEdit; const save = (patch: NotificationTypePolicyPatch) => { startTransition(async () => { @@ -106,134 +172,277 @@ const TypeRow = ({ } toast.success(t("success"), { - description: t("success_desc", { type: type.id }), + description: t("success_desc", { type: type.label }), }); }); }; - const isLocked = !canEdit || isPending; - const defaultInApp = policy.inApp ?? type.defaults.inApp; - const defaultEmail = policy.email ?? type.defaults.email; + const short = { + available: t("summary.available"), + default_off: t("summary.off"), + default_on: t("summary.on"), + disabled: t("summary.disabled"), + }; + 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 ( - <li - aria-busy={isPending} - className="flex flex-col gap-4 border-b py-4 last:border-b-0 lg:flex-row lg:items-start lg:justify-between" - > - <div className="flex min-w-0 flex-col gap-2"> - <div className="flex flex-col gap-1"> - <h3 className="font-mono text-sm font-medium break-all">{type.id}</h3> - <p className="text-muted-foreground text-sm"> - {type.pluginId} · {type.category} ·{" "} - {t("version", { version: type.version })} - </p> - </div> - <ul className="flex flex-wrap gap-2"> - <li> - <Badge variant="secondary">{t("in_app")}</Badge> - </li> - {type.emailSupported ? ( - <li> - <Badge variant={type.emailAvailable ? "success" : "warning"}> - {type.emailAvailable ? t("email") : t("email_unavailable")} - </Badge> - </li> + <li className="border-b last:border-b-0"> + <div className="has-focus-visible:ring-ring/50 [@media(hover:hover)]:hover:bg-muted/40 relative flex flex-col gap-3 px-4 py-4 has-focus-visible:ring-[3px] has-focus-visible:ring-inset sm:px-6 md:flex-row md:items-center md:justify-between"> + <div className="flex min-w-0 flex-col gap-1"> + <h4 + className={cn( + "text-sm leading-snug font-medium text-pretty break-words", + isSentNowhere(choices) && "text-muted-foreground", + )} + > + <button + aria-controls={panelId} + aria-expanded={isOpen} + className="text-start outline-none after:absolute after:inset-0" + onClick={onToggle} + type="button" + > + {type.label} + </button> + </h4> + <span className="text-muted-foreground font-mono text-xs break-all"> + {type.id} + </span> + {type.mandatory || + !choices.memberCanEdit || + isSentNowhere(choices) || + type.grouped || + (type.emailSupported && + !type.emailAvailable && + choices.email !== "disabled") ? ( + <span className="flex flex-wrap gap-1"> + {type.mandatory ? ( + <Badge variant="outline"> + <LockIcon aria-hidden /> + {t("badges.mandatory")} + </Badge> + ) : !choices.memberCanEdit ? ( + <Badge variant="outline"> + <LockIcon aria-hidden /> + {t("badges.locked")} + </Badge> + ) : null} + {isSentNowhere(choices) ? ( + <Badge variant="secondary">{t("badges.nowhere")}</Badge> + ) : null} + {type.grouped ? ( + <Badge variant="secondary">{t("badges.grouped")}</Badge> + ) : null} + {type.emailSupported && + !type.emailAvailable && + choices.email !== "disabled" ? ( + <Badge variant="warning">{t("badges.no_template")}</Badge> + ) : null} + </span> ) : null} - {type.grouped ? ( - <li> - <Badge variant="outline">{t("grouped")}</Badge> - </li> - ) : null} - {type.mandatory ? ( - <li> - <Badge variant="outline">{t("mandatory")}</Badge> - </li> - ) : null} - </ul> + </div> + <div className="flex items-center justify-between gap-3 md:justify-end"> + <ul + aria-label={t("summary.label", { type: type.label })} + className="flex flex-wrap gap-1.5" + > + <SummaryChip + Icon={ListIcon} + label={t("summary.list")} + muted={choices.list === "disabled"} + value={short[choices.list]} + /> + <SummaryChip + Icon={SmartphoneIcon} + label={t("summary.push")} + muted={choices.push === "disabled"} + value={short[choices.push]} + /> + <SummaryChip + Icon={MailIcon} + label={t("summary.email")} + muted={choices.email === null || choices.email === "disabled"} + value={ + choices.email === null + ? t("summary.unsupported") + : short[choices.email] + } + /> + </ul> + <ChevronDownIcon + aria-hidden + className={cn( + "text-muted-foreground size-4 shrink-0 transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none", + isOpen && "rotate-180", + )} + /> + </div> </div> - <div className="grid shrink-0 gap-4 sm:grid-cols-2 lg:w-md"> - <SwitchField - checked={type.mandatory || policy.enabled} - disabled={isLocked || type.mandatory} - hint={type.mandatory ? t("enabled_mandatory") : undefined} - label={t("enabled")} - onCheckedChange={enabled => { - save({ enabled }); - }} - /> - <SwitchField - checked={defaultInApp} - disabled={isLocked} - label={t("default_in_app")} - onCheckedChange={inApp => { - save({ inApp }); - }} - /> - {type.emailSupported ? ( - <> - <SwitchField - checked={policy.allowEmail} - disabled={isLocked} - label={t("allow_email")} - onCheckedChange={allowEmail => { - save({ allowEmail }); - }} - /> - <div className="flex flex-col gap-2"> - <Label className="font-normal" htmlFor={selectId}> - {t("default_email")} - </Label> - <NativeSelect - className="w-full" - disabled={isLocked || !policy.allowEmail} - id={selectId} - onChange={event => { - const email = event.target.value; - if (isEmailMode(email)) save({ email }); - }} - size="sm" - value={defaultEmail} - > - {NOTIFICATION_EMAIL_MODES.map(mode => ( - <NativeSelectOption key={mode} value={mode}> - {t(`email_modes.${mode}`)} - </NativeSelectOption> - ))} - </NativeSelect> + <AnimatePresence initial={false}> + {isOpen ? ( + <motion.div + animate={REVEAL_SHOWN} + className="overflow-y-clip" + exit={ + shouldReduceMotion + ? undefined + : { ...REVEAL_HIDDEN, transition: REVEAL_EXIT_TRANSITION } + } + id={panelId} + initial={shouldReduceMotion ? false : REVEAL_HIDDEN} + key="panel" + transition={REVEAL_TRANSITION} + > + <div className="bg-muted/30 flex flex-col gap-6 border-t px-4 py-5 sm:px-6"> + <div className="flex items-start justify-between gap-4"> + <Label + className="flex flex-col items-start gap-1 font-normal" + htmlFor={memberId} + > + <span className="flex items-center gap-2 text-sm font-medium"> + <LockIcon + aria-hidden + className="text-muted-foreground size-4" + /> + {t("member_can_edit.label")} + </span> + <span className="text-muted-foreground text-xs leading-relaxed text-pretty"> + {type.mandatory + ? t("member_can_edit.mandatory") + : t("member_can_edit.desc")} + </span> + </Label> + <Switch + checked={choices.memberCanEdit} + disabled={locked || type.mandatory} + id={memberId} + onCheckedChange={memberCanEdit => { + save({ memberCanEdit }); + }} + /> + </div> + + <div className="grid gap-6 lg:grid-cols-3"> + <ChoiceGroup + disabled={locked || type.mandatory} + Icon={ListIcon} + legend={t("list.legend")} + note={type.mandatory ? t("list.mandatory") : undefined} + onChange={choice => { + save(listPatch(choice)); + }} + options={listOptions} + value={choices.list} + /> + <ChoiceGroup + disabled={locked} + Icon={SmartphoneIcon} + legend={t("push.legend")} + note={t("push.note")} + onChange={choice => { + save(pushPatch(choice)); + }} + options={pushOptions} + value={choices.push} + /> + {choices.email === null ? ( + <div className="flex min-w-0 flex-col"> + <p className="mb-3 flex items-center gap-2 text-sm font-medium"> + <MailIcon + aria-hidden + className="text-muted-foreground size-4" + /> + {t("email.legend")} + </p> + <p className="text-muted-foreground rounded-lg border border-dashed p-3 text-sm leading-relaxed"> + {t("email.unsupported")} + </p> + </div> + ) : ( + <ChoiceGroup + disabled={locked} + Icon={MailIcon} + legend={t("email.legend")} + onChange={choice => { + save(emailPatch(choice, type)); + }} + options={emailOptions} + value={choices.email} + /> + )} + </div> </div> - </> + </motion.div> ) : null} - </div> + </AnimatePresence> </li> ); }; -export const NotificationsTypesCard = ({ +export const NotificationsTypesSection = ({ + actions, canEdit, - onUpdate, + canManage, + customizedMembers, types, }: { + actions: NotificationsAdminActions; canEdit: boolean; - onUpdate: NotificationsAdminActions["updateTypePolicy"]; + canManage: boolean; + customizedMembers: number; types: AdminNotificationType[]; }) => { const t = useTranslations("admin.system.notifications.types"); + const [openId, setOpenId] = React.useState<null | string>(null); + const groups = [...new Set(types.map(type => type.pluginId))].map( + pluginId => ({ + pluginId, + types: types.filter(type => type.pluginId === pluginId), + }), + ); return ( - <Card aria-labelledby="notifications-types" role="region"> - <CardHeader> - <CardTitle> - <h2 className="text-balance" id="notifications-types"> + <section + aria-labelledby="notifications-types" + className="flex flex-col gap-4" + > + <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> + <div className="flex max-w-2xl flex-col gap-1"> + <h2 + className="text-lg font-semibold text-balance" + id="notifications-types" + > {t("title")} </h2> - </CardTitle> - <CardDescription className="leading-relaxed text-pretty"> - {canEdit ? t("desc") : `${t("desc")} ${t("read_only")}`} - </CardDescription> - </CardHeader> - <CardContent> - {types.length === 0 ? ( + <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {canEdit ? t("desc") : `${t("desc")} ${t("read_only")}`} + </p> + </div> + {canManage ? ( + <ResetMembersButton + count={customizedMembers} + onReset={actions.resetMemberPreferences} + /> + ) : null} + </div> + + {types.length === 0 ? ( + <Card> <Empty> <EmptyHeader> <EmptyMedia variant="icon"> @@ -243,19 +452,34 @@ export const NotificationsTypesCard = ({ <EmptyDescription>{t("empty.desc")}</EmptyDescription> </EmptyHeader> </Empty> - ) : ( - <ul className="flex flex-col"> - {types.map(type => ( - <TypeRow - canEdit={canEdit} - key={type.id} - onUpdate={onUpdate} - type={type} - /> - ))} - </ul> - )} - </CardContent> - </Card> + </Card> + ) : ( + groups.map(group => ( + <div className="flex flex-col gap-2" key={group.pluginId}> + <h3 className="text-muted-foreground text-xs font-medium"> + {group.pluginId} + </h3> + <Card className="gap-0 py-0"> + <ul> + {group.types.map(type => ( + <TypeItem + canEdit={canEdit} + isOpen={openId === type.id} + key={type.id} + onToggle={() => { + setOpenId(current => + current === type.id ? null : type.id, + ); + }} + onUpdate={actions.updateTypePolicy} + type={type} + /> + ))} + </ul> + </Card> + </div> + )) + )} + </section> ); }; diff --git a/packages/vitnode/src/views/notifications/notifications-query.ts b/packages/vitnode/src/views/notifications/notifications-query.ts index 1edb259db..457d97f6e 100644 --- a/packages/vitnode/src/views/notifications/notifications-query.ts +++ b/packages/vitnode/src/views/notifications/notifications-query.ts @@ -166,7 +166,9 @@ export interface NotificationPreferenceTypeView { description: null | string; emailModes: ("daily" | "immediate" | "none" | "weekly")[]; id: string; + inAppAvailable: boolean; label: string; + locked: boolean; mandatory: boolean; pluginId: string; value: { email: "daily" | "immediate" | "none" | "weekly"; inApp: boolean }; 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<NotificationWorkerSettings>; plugins: BuildPluginApiReturn[]; rateLimiter?: Omit<IRateLimiterOptions, "keyPrefix">; From 7bb9f4ff4fba7eeba3baa09821250e09c284b5be Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axen@vitnode.com> Date: Sun, 4 Oct 2026 17:04:45 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat(notifications):=20=E2=9C=89=EF=B8=8F?= =?UTF-8?q?=20Implement=20email=20and=20push=20notification=20settings=20w?= =?UTF-8?q?ith=20new=20UI=20components=20and=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 7 + .../snapshot.json | 11310 ++++++++++++++++ .../migration.sql | 3 + .../snapshot.json | 11284 +++++++++++++++ .../migration.sql | 1 + .../snapshot.json | 11284 +++++++++++++++ .../docs/dev/events/built-in-events.mdx | 64 +- .../docs/dev/notifications/admincp.mdx | 62 +- .../docs/dev/notifications/blog-example.mdx | 265 - .../dev/notifications/email-and-digests.mdx | 24 +- .../dev/notifications/inbox-and-realtime.mdx | 49 +- .../content/docs/dev/notifications/index.mdx | 29 +- .../content/docs/dev/notifications/meta.json | 1 - .../migration-and-troubleshooting.mdx | 61 +- .../dev/notifications/notification-types.mdx | 64 +- .../docs/dev/notifications/preferences.mdx | 114 +- .../docs/dev/notifications/publishing.mdx | 56 +- .../dev/notifications/queue-and-scale.mdx | 64 +- .../docs/dev/working-with-users/users.mdx | 17 + apps/web/src/locales/@vitnode/blog/pl.json | 3 +- apps/web/src/locales/@vitnode/core/pl.json | 114 +- packages/vitnode/src/api/lib/module.ts | 9 +- .../api/lib/notifications/preferences.test.ts | 34 +- .../src/api/lib/notifications/preferences.ts | 38 +- .../api/lib/notifications/registry.test.ts | 26 - .../src/api/lib/notifications/registry.ts | 80 - packages/vitnode/src/api/lib/plugin.ts | 15 +- .../src/api/middlewares/global.middleware.ts | 8 +- packages/vitnode/src/api/models/events.ts | 3 - .../src/api/models/notifications/admin.ts | 39 +- .../notifications/danger.integration.test.ts | 110 +- .../src/api/models/notifications/danger.ts | 6 +- .../models/notifications/email-cap.test.ts | 49 - .../src/api/models/notifications/email-cap.ts | 46 - .../notifications/email.integration.test.ts | 11 +- .../src/api/models/notifications/email.ts | 142 +- .../fanout.load.integration.test.ts | 48 +- .../src/api/models/notifications/fanout.ts | 94 +- .../src/api/models/notifications/index.ts | 29 - .../api/models/notifications/preferences.ts | 43 +- .../src/api/models/notifications/publish.ts | 16 +- .../src/api/models/notifications/shared.ts | 28 +- .../api/models/notifications/subscriptions.ts | 132 - .../src/api/models/user/get-user-by-id.ts | 1 + .../notifications.admin.module.ts | 8 +- .../notifications/routes/maintenance.route.ts | 28 - .../notifications/routes/overview.route.ts | 11 +- .../notifications/routes/settings.route.ts | 42 +- .../notifications/notifications.module.ts | 8 - .../notifications/routes/preferences.route.ts | 15 +- .../routes/subscriptions.route.ts | 169 - .../notifications/tasks/notification-tasks.ts | 2 +- .../api/modules/users/routes/session.route.ts | 1 + .../users/routes/time-zone.route.test.ts | 78 + .../modules/users/routes/time-zone.route.ts | 72 + .../src/api/modules/users/users.module.ts | 2 + .../server/localized-admin-routes.test.ts | 1 + .../server/localized-list-routes.test.ts | 1 + .../content/server/pagination-routes.test.ts | 1 + .../content/server/permission-matrix.test.ts | 1 + .../translation-editorial-routes.test.ts | 1 + .../translation-publication-routes.test.ts | 1 + .../content/server/translation-routes.test.ts | 1 + .../vitnode/src/database/notifications.ts | 42 +- packages/vitnode/src/database/users.ts | 1 + packages/vitnode/src/locales/en.json | 152 +- .../src/pages/settings/notifications.tsx | 19 +- .../src/tanstack/admin/notifications/query.ts | 2 - .../tanstack/admin/notifications/screen.tsx | 18 +- .../vitnode/src/tanstack/auth/state.test.ts | 1 + .../notifications/follow-subject-button.tsx | 124 - .../src/tanstack/notifications/index.ts | 1 - .../notifications/settings-screen.tsx | 464 +- .../src/tanstack/settings/overview.tsx | 14 + packages/vitnode/src/tests/notifications.ts | 8 +- .../notifications/notifications-mutations.ts | 42 - .../notifications/notifications-query.ts | 11 +- .../notifications/settings-sheet-content.tsx | 696 +- .../system/notifications/settings-sheet.tsx | 4 +- .../views/auth/settings/overview/overview.tsx | 7 + .../settings/overview/time-zone-editor.tsx | 119 + .../auth/settings/overview/time-zone-row.tsx | 81 + .../settings/overview/time-zone-update.ts | 48 + .../notifications/notifications-actions.ts | 41 +- .../notifications/notifications-query.ts | 78 +- .../notifications/settings/channels-card.tsx | 217 + .../settings/email-channel.test.ts | 103 + .../notifications/settings/email-channel.ts | 53 + .../notifications/settings/type-item.tsx | 299 + plugins/blog/src/api/lib/notifications.ts | 198 - .../src/api/modules/admin/admin.module.ts | 3 +- plugins/blog/src/config.api.ts | 8 - plugins/blog/src/locales/api/en.json | 12 - plugins/blog/src/locales/api/index.ts | 8 - plugins/blog/src/locales/en.json | 3 +- plugins/blog/src/pages/post-page.tsx | 13 - 96 files changed, 35904 insertions(+), 3312 deletions(-) create mode 100644 apps/api/migrations/20261004130641_remove_notification_follows_move_time_zone/migration.sql create mode 100644 apps/api/migrations/20261004130641_remove_notification_follows_move_time_zone/snapshot.json create mode 100644 apps/api/migrations/20261004144103_remove_notification_digest_schedule/migration.sql create mode 100644 apps/api/migrations/20261004144103_remove_notification_digest_schedule/snapshot.json create mode 100644 apps/api/migrations/20261004145703_remove_notification_email_settings/migration.sql create mode 100644 apps/api/migrations/20261004145703_remove_notification_email_settings/snapshot.json delete mode 100644 apps/web/content/docs/dev/notifications/blog-example.mdx delete mode 100644 packages/vitnode/src/api/models/notifications/email-cap.test.ts delete mode 100644 packages/vitnode/src/api/models/notifications/email-cap.ts delete mode 100644 packages/vitnode/src/api/models/notifications/subscriptions.ts delete mode 100644 packages/vitnode/src/api/modules/notifications/routes/subscriptions.route.ts create mode 100644 packages/vitnode/src/api/modules/users/routes/time-zone.route.test.ts create mode 100644 packages/vitnode/src/api/modules/users/routes/time-zone.route.ts delete mode 100644 packages/vitnode/src/tanstack/notifications/follow-subject-button.tsx create mode 100644 packages/vitnode/src/views/auth/settings/overview/time-zone-editor.tsx create mode 100644 packages/vitnode/src/views/auth/settings/overview/time-zone-row.tsx create mode 100644 packages/vitnode/src/views/auth/settings/overview/time-zone-update.ts create mode 100644 packages/vitnode/src/views/notifications/settings/channels-card.tsx create mode 100644 packages/vitnode/src/views/notifications/settings/email-channel.test.ts create mode 100644 packages/vitnode/src/views/notifications/settings/email-channel.ts create mode 100644 packages/vitnode/src/views/notifications/settings/type-item.tsx delete mode 100644 plugins/blog/src/api/lib/notifications.ts delete mode 100644 plugins/blog/src/locales/api/en.json delete mode 100644 plugins/blog/src/locales/api/index.ts 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/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index 2cb8a81df..908808af8 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -42,22 +42,22 @@ Fourteen names, all declared in `VitNodeEvents` in `packages/vitnode/src/api/models/events.ts`. Every one of them fires **after** the write it describes has committed. -| Event | Payload | Fires when | -| --------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `user.created` | `{ userId, email, name, emailVerified }` | A user row is inserted - public sign-up, AdminCP creation, or SSO sign-up | -| `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP (profile fields and/or role assignments) | -| `user.deleted` | `{ userId, email }` | Never - the name is declared for plugins, core has no deletion flow | -| `user.sso.linked` | `{ userId, email, providerId }` | An SSO identity is linked to an existing account - at login with its password, or from **Settings → Connected accounts** | -| `user.sso.unlinked` | `{ userId, providerId }` | A member disconnects a provider in **Settings → Connected accounts** | -| `user.sso.profile_synced` | `{ userId, providerId, fields, trigger }` | Profile fields were copied from a provider - by **Sync now**, **Import** or an opted-in sign-in | -| `user.passkey.created` | `{ userId, passkeyId }` | A member adds a [passkey](/docs/dev/passkeys) in **Settings → Security** | -| `user.passkey.updated` | `{ userId, passkeyId, name }` | A member renames one of their passkeys | -| `user.passkey.deleted` | `{ userId, passkeyId }` | A member removes one of their passkeys | -| `user.avatar.updated` | `{ userId, fileId }` | An avatar is uploaded or removed - by the user on their profile, or by staff in the AdminCP | -| `user.cover.updated` | `{ userId, fileId }` | A profile cover is uploaded or removed - by the user on their profile, or by staff in the AdminCP | -| `role.created` | `{ roleId }` | A role is created in the AdminCP | -| `role.updated` | `{ roleId }` | A role is edited in the AdminCP | -| `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP | +| Event | Payload | Fires when | +| ------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `user.created` | `{ userId, email, name, emailVerified }` | A user row is inserted - public sign-up, AdminCP creation, or SSO sign-up | +| `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP, or a member saves their own profile or time zone in **Settings → Overview** | +| `user.deleted` | `{ userId, email }` | Never - the name is declared for plugins, core has no deletion flow | +| `user.sso.linked` | `{ userId, email, providerId }` | An SSO identity is linked to an existing account - at login with its password, or from **Settings → Connected accounts** | +| `user.sso.unlinked` | `{ userId, providerId }` | A member disconnects a provider in **Settings → Connected accounts** | +| `user.sso.profile_synced` | `{ userId, providerId, fields, trigger }` | Profile fields were copied from a provider - by **Sync now**, **Import** or an opted-in sign-in | +| `user.passkey.created` | `{ userId, passkeyId }` | A member adds a [passkey](/docs/dev/passkeys) in **Settings → Security** | +| `user.passkey.updated` | `{ userId, passkeyId, name }` | A member renames one of their passkeys | +| `user.passkey.deleted` | `{ userId, passkeyId }` | A member removes one of their passkeys | +| `user.avatar.updated` | `{ userId, fileId }` | An avatar is uploaded or removed - by the user on their profile, or by staff in the AdminCP | +| `user.cover.updated` | `{ userId, fileId }` | A profile cover is uploaded or removed - by the user on their profile, or by staff in the AdminCP | +| `role.created` | `{ roleId }` | A role is created in the AdminCP | +| `role.updated` | `{ roleId }` | A role is edited in the AdminCP | +| `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP | <Accordions> <Accordion title="user.created"> @@ -109,8 +109,8 @@ export const welcomeListener = buildEventListener({ <Accordion title="user.updated"> 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. <TypeTable type={{ @@ -404,21 +404,20 @@ or the bottom bar, never both. ## Notification events -Eight names, declared in `VitNodeEvents` in +Seven names, declared in `VitNodeEvents` in `packages/vitnode/src/api/models/events.ts`. Each fires **after** an administrator's change in [AdminCP → Notifications](/docs/dev/notifications/admincp) has been saved. The envelope's `actor` is that administrator. -| Event | Payload | Fires when | -| ---------------------------------- | ------------------------ | -------------------------------------------------------------------------------- | -| `notifications.settings.updated` | `{ keys }` | Installation settings are saved - `keys` lists the settings that were sent | -| `notifications.type.updated` | `{ typeId }` | What a type does by default (list, push, email, member can edit) changes | -| `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 | +| Event | Payload | Fires when | +| --------------------------------- | -------------------- | -------------------------------------------------------------------------- | +| `notifications.type.updated` | `{ typeId }` | What a type does by default (list, push, email, member can edit) changes | +| `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. @@ -831,9 +830,10 @@ handler: async (c, payload) => { An interactive publish is emitted once, by the route that performed it, and carries no `scheduleId`. -The blog's own `content.blog.post.published` listener shows the other way out: -it announces the article with a notification idempotency key, so a second run -is a no-op. See [Example: blog category followers](/docs/dev/notifications/blog-example). +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 diff --git a/apps/web/content/docs/dev/notifications/admincp.mdx b/apps/web/content/docs/dev/notifications/admincp.mdx index 6ac590890..7ff649d9d 100644 --- a/apps/web/content/docs/dev/notifications/admincp.mdx +++ b/apps/web/content/docs/dev/notifications/admincp.mdx @@ -14,11 +14,11 @@ tidy up when you need to. 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** | +| 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). @@ -44,12 +44,12 @@ If notifications are [paused](#danger-zone), a banner at the top says so. 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 | -| --------------------- | --------------------------------------------------------------- | +| 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 | +| **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. @@ -72,46 +72,42 @@ daily or weekly) the type had before. ### Reset all members to defaults The button next to **Notification types** wipes every member's own choices - -muted types, email choices and their digest schedule - so everyone follows the -defaults on this page again. The confirmation says how many members picked +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. -Follows and muted subjects (like a muted blog category) are kept. +A member's time zone is part of their account, so a reset keeps it. ## Settings -The **Settings** button opens a sheet. Every row shows its current value; tap -it to change that one value and save. Switches save right away. +The **Settings** button opens a sheet with tools and the danger zone. Only +staff with the `can_manage` permission see it. -| Setting | Default | Range | Effect | -| -------------------------- | ------- | ------- | -------------------------------------------------------------------------------------------- | -| Send notification emails | On | - | Off keeps every notification in the list, whatever members picked. Account email is unaffected | -| Emails per member per hour | No limit | 0-500 | Immediate emails over the limit wait until the hour is up. Digests are never held back | -| Daily digest time | 08:00 | 0-23 | For members who haven't picked their own, in their time zone | -| Weekly digest day | Monday | - | For members who haven't picked their own | - -Retention and worker batch sizes are deployment settings, set in your API -config rather than here - see +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. -- **Run cleanup now** removes notifications older than the configured - retention period now instead of waiting for 03:17. + 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 | +| 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 | +| **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. @@ -125,10 +121,8 @@ All routes live under `/api/@vitnode/core/admin/notifications`: | `GET` | `/overview` | `can_view` | | `GET` | `/stats` | `can_view` | | `GET` | `/deliveries` | `can_view` | -| `PUT` | `/settings` | `can_edit` | | `PUT` | `/types/{type}` | `can_edit` | | `POST` | `/test-email` | `can_manage` | -| `POST` | `/cleanup` | `can_manage` | | `POST` | `/reconcile` | `can_manage` | | `POST` | `/deliveries/{id}/retry` | `can_manage` | | `POST` | `/pause` | `can_manage` | diff --git a/apps/web/content/docs/dev/notifications/blog-example.mdx b/apps/web/content/docs/dev/notifications/blog-example.mdx deleted file mode 100644 index ce685a461..000000000 --- a/apps/web/content/docs/dev/notifications/blog-example.mdx +++ /dev/null @@ -1,265 +0,0 @@ ---- -title: 'Example: blog category followers' -description: A real VitNode notifications integration - the blog plugin tells readers who follow a category when a new article is published, with a follow button on every article. -icon: Newspaper ---- - -The `@vitnode/blog` plugin uses the Notifications Center for one feature: readers -follow a blog category, and get "New article: …" when an article in it is -published. All of it lives in `plugins/blog/src/api/lib/notifications.ts`, and -it touches every part of the API. - -<Steps> - -<Step> - -### Declare the type - -```ts title="plugins/blog/src/api/lib/notifications.ts" -export const postPublishedNotification = buildNotificationType({ - id: 'blog.post_published', - version: 1, - schema: z.object({ - postId: z.number().int().positive(), - slug: z.string().min(1).max(255), - title: z.string().min(1).max(255), - }), - category: 'content', - label: '@vitnode/blog.notifications.post_published.label', - description: '@vitnode/blog.notifications.post_published.description', - subjectType: 'blog.post', - defaults: { email: 'daily', inApp: true }, - email: true, - access: async ({ c, data, userIds }) => - (await isPublished(c, data.postId)) ? userIds : [], - present: ({ data, t }) => ({ - body: t('@vitnode/blog.notifications.post_published.body'), - target: `/blog/${encodeURIComponent(data.slug)}`, - title: t('@vitnode/blog.notifications.post_published.title', { - title: data.title, - }), - }), -}) -``` - -- **`access`** gives every recipient the same answer, so it checks once and - returns all of `userIds` or none. Unpublish the article, and inbox items turn - into placeholders and pending digests drop it. -- **`subjectType: "blog.post"`** - no plugin declares a `blog.post` subject, so - core makes it mute-only. A reader can mute one article without unfollowing - the category. -- **`defaults`** - in-app, plus a daily digest by email. - -</Step> - -<Step> - -### Declare the subject readers follow - -```ts title="plugins/blog/src/api/lib/notifications.ts" -export const blogCategorySubject = buildNotificationSubject({ - type: 'blog.category', - canFollow: async ({ c, id }) => { - const categoryId = Number(id) - if (!Number.isSafeInteger(categoryId)) return false - - const [row] = await c - .get('db') - .select({ id: blog_categories.id }) - .from(blog_categories) - .where(eq(blog_categories.id, categoryId)) - .limit(1) - - return !!row - }, - resolveLabels: async ({ c, ids, locale }) => { - const categoryIds = ids.map(Number).filter(Number.isSafeInteger) - if (categoryIds.length === 0) return {} - - const rows = await c - .get('db') - .select({ - code: core_languages.code, - id: categoryTranslations.itemId, - name: categoryTranslations.name, - }) - .from(categoryTranslations) - .innerJoin( - core_languages, - eq(core_languages.id, categoryTranslations.languageId), - ) - .where(inArray(categoryTranslations.itemId, categoryIds)) - - const labels: Record<string, string> = {} - for (const row of rows) { - // The reader's language wins; any other translation is a fallback. - if (row.code === locale || !(String(row.id) in labels)) { - labels[String(row.id)] = row.name - } - } - - return labels - }, -}) -``` - -`canFollow` only allows categories that exist. `resolveLabels` turns category -ids into names for the follow list on `/settings/notifications`, in the -reader's language, with one query for all ids. - -</Step> - -<Step> - -### Register both, with the API messages - -```ts title="plugins/blog/src/config.api.ts" -import apiMessages from '@/locales/api' - -export const blogApiPlugin = () => - buildApiPlugin({ - pluginId: CONFIG_PLUGIN.pluginId, - messages: apiMessages, - notificationTypes: [postPublishedNotification], - notificationSubjects: [blogCategorySubject], - modules: [adminModule /* ... */], - }) -``` - -```json title="plugins/blog/src/locales/api/en.json" -{ - "@vitnode/blog": { - "notifications": { - "post_published": { - "label": "New articles in categories you follow", - "description": "When an article is published in a blog category you follow.", - "title": "New article: {title}", - "body": "Fresh off the press in a category you follow." - } - } - } -} -``` - -</Step> - -<Step> - -### Publish from the Content Engine event - -The blog runs on the [Content Engine](/docs/dev/content-engine), which emits -`content.blog.post.published`. A listener turns it into one notification for -the followers of every category the article is in. `loadPostSummary()` reads -the article's title and slug in its default language: - -```ts title="plugins/blog/src/api/lib/notifications.ts" -export const notifyCategoryFollowersListener = buildEventListener({ - event: contentEventName(blogPostContentType.id, 'published'), - name: 'notify-category-followers', - handler: async (c, payload) => { - const postId = payload.contentId - const [summary, categories] = await Promise.all([ - loadPostSummary(c, postId), - c - .get('db') - .select({ categoryId: blog_posts_category_id.relatedItemId }) - .from(blog_posts_category_id) - .where(eq(blog_posts_category_id.itemId, postId)) - .orderBy(asc(blog_posts_category_id.position)), - ]) - if (!summary || categories.length === 0) return - - await c.get('notifications').publish({ - type: postPublishedNotification, - actorId: payload.scheduledBy ?? c.get('admin')?.user.id ?? null, - followersOf: categories.map((row) => ({ - id: row.categoryId, - type: 'blog.category', - })), - subject: { id: postId, type: 'blog.post' }, - data: { postId, slug: summary.slug, title: summary.title }, - idempotencyKey: `post-published:${postId}`, - }) - }, -}) -``` - -It is registered on the top-level admin module: - -```ts title="plugins/blog/src/api/modules/admin/admin.module.ts" -events: [...blogLegacyEventListeners, notifyCategoryFollowersListener], -``` - -- **`followersOf`** names up to 20 categories. A reader following two of them - gets one notification. Core streams the followers, so a category with ten - thousand of them still costs this listener one query. -- **`actorId`** is whoever pressed publish, or whoever booked the schedule. They - are left out of their own announcement. -- **`idempotencyKey`** is per article. An article is announced once, like its - `publishedAt` that records the first publication: unpublishing and publishing - again, or a retried scheduled publish, does not notify anyone twice. - -</Step> - -<Step> - -### Add the follow button - -```tsx title="plugins/blog/src/pages/post-page.tsx" -import { FollowSubjectButton } from '@vitnode/core/tanstack/notifications' - -// Rendered in the article header, under the title -const followCategory = - item.categoryId[0] === undefined ? null : ( - <div className="flex flex-wrap items-center gap-3"> - <p className="text-muted-foreground text-sm leading-relaxed"> - {t('follow_category')} - </p> - <FollowSubjectButton - subject={{ id: item.categoryId[0], type: 'blog.category' }} - /> - </div> - ) -``` - -The article's `categoryId` list comes from the public content API. The button -follows the first category. - -Guests see nothing. Signed-in readers get **Follow** and a mute toggle. - -</Step> - -</Steps> - -{/* Image prompt: A VitNode blog article page on a phone, 390x844, light theme. Under the article title, a muted line "Want to hear about new articles in this category?" next to an outlined "Follow" button with a bell-plus icon and a small square mute button with a bell-off icon. */} - -## How durable is it? - -<Callout type="warn" title="An interactive publish is announced best effort"> - The Content Engine emits events for interactive saves **after** its - transaction commits. If the process crashes between that commit and the - listener's `publish()`, that one announcement is skipped. The article itself - is safe. -</Callout> - -Scheduled publication runs in a durable queue task that retries until it -succeeds. It is **at least once**, so the listener can run twice - and the -idempotency key turns the second run into a no-op. - -For producers you own, skip the gap entirely: call `publish()` with `tx` inside -the same transaction as your write. See -[Publish inside your transaction](/docs/dev/notifications/publishing#publish-inside-your-transaction). - -## Related - -<Cards> - <Card - title="Built-in events" - description="content.blog.post.published and every other event" - href="/docs/dev/events/built-in-events" - /> - <Card - title="Notification types" - href="/docs/dev/notifications/notification-types" - /> -</Cards> diff --git a/apps/web/content/docs/dev/notifications/email-and-digests.mdx b/apps/web/content/docs/dev/notifications/email-and-digests.mdx index 4e99db882..648ef4773 100644 --- a/apps/web/content/docs/dev/notifications/email-and-digests.mdx +++ b/apps/web/content/docs/dev/notifications/email-and-digests.mdx @@ -19,25 +19,17 @@ 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`. -### Hourly limit - -**Emails per member per hour** in the AdminCP caps immediate emails. Ones over -the limit aren't dropped: they wait until the oldest email of that hour is an -hour old, then go out. Digests are never held back. `0` (the default) means no -limit. - ## 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 their `digestHour`. A weekly one covers seven days - ending on their `digestWeekday`. Members who never picked follow the - installation default from the [AdminCP](/docs/dev/notifications/admincp#settings) - - 08:00, Monday out of the box. -- **Time zone fallback.** The user's own time zone, then the time zone of their - language, then UTC. + 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 @@ -55,8 +47,8 @@ are waiting. 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, and their mutes; -- the installation policy and the global email switch; +- 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. @@ -90,7 +82,7 @@ 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 from the AdminCP](/docs/dev/notifications/admincp#deliveries) reuses +[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. diff --git a/apps/web/content/docs/dev/notifications/inbox-and-realtime.mdx b/apps/web/content/docs/dev/notifications/inbox-and-realtime.mdx index 4ac1e98e8..8b2ba88fb 100644 --- a/apps/web/content/docs/dev/notifications/inbox-and-realtime.mdx +++ b/apps/web/content/docs/dev/notifications/inbox-and-realtime.mdx @@ -139,19 +139,16 @@ See [Redis](/docs/dev/advanced/redis) and [WebSocket](/docs/dev/websocket). 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, channels, digest schedule and time zone | -| `PUT` | `/preferences` | Save them | -| `GET` | `/subscriptions` | Followed and muted subjects, optional `state` filter | -| `GET` | `/subscriptions/{subjectType}/{subjectId}` | One subject's state | -| `PUT` | `/subscriptions` | Body `{ subjectType, subjectId, state }`, state is `following`, `muted` or `none` | +| 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. @@ -171,26 +168,12 @@ const response = await fetcher({ 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` - per type, digest schedule, follows and mutes | -| `NotificationStateSync` | Mounted with the realtime listeners. Keeps the count in step | -| `FollowSubjectButton` | Drop it on your own plugin pages | - -Add follow and mute controls to any page: - -```tsx title="plugins/forum/src/pages/topic-page.tsx" -import { FollowSubjectButton } from '@vitnode/core/tanstack/notifications' - -export const FollowTopic = ({ topicId }: { topicId: number }) => ( - <FollowSubjectButton subject={{ type: 'forum.topic', id: topicId }} /> -) -``` - -It renders nothing for guests. Pass `canMute={false}` to hide the mute toggle. -The subject type must be registered with `buildNotificationSubject()`. +| 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". */} diff --git a/apps/web/content/docs/dev/notifications/index.mdx b/apps/web/content/docs/dev/notifications/index.mdx index b3a0b8209..9c2314ff1 100644 --- a/apps/web/content/docs/dev/notifications/index.mdx +++ b/apps/web/content/docs/dev/notifications/index.mdx @@ -1,6 +1,6 @@ --- title: Notifications -description: Add persistent notifications to a VitNode plugin - an inbox, a live unread count, follow and mute, email and daily or weekly digests, all from one publish call. +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 --- @@ -103,7 +103,7 @@ The topic author sees it in the bell a moment after the response is sent. See ### Apply the database migration -The Notifications Center adds seven `core_notification_*` tables. Build and +The Notifications Center adds six `core_notification*` tables. Build and migrate once after upgrading: <Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Build and migrate"> @@ -128,14 +128,14 @@ npm run build:plugins && npm run db:migrate ## Who does what -| Your plugin | Core | -| ---------------------------------------------------------- | ----------------------------------------------------------------- | -| Declares types and subjects | Builds the preferences page from them | -| Picks candidates: `recipients` and `followersOf` | Removes the actor, deleted users, muted 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 | +| 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 @@ -144,7 +144,7 @@ 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 → muted? opted out? access? +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 @@ -169,7 +169,7 @@ fan-out, in batches of 500 candidates → muted? opted out? access? <Cards> <Card title="Notification types" - description="Every field of buildNotificationType and buildNotificationSubject" + description="Every field of buildNotificationType" href="/docs/dev/notifications/notification-types" /> <Card @@ -192,9 +192,4 @@ fan-out, in batches of 500 candidates → muted? opted out? access? description="Immediate email, daily and weekly digests, retries" href="/docs/dev/notifications/email-and-digests" /> - <Card - title="Blog example" - description="A real integration, from type to follow button" - href="/docs/dev/notifications/blog-example" - /> </Cards> diff --git a/apps/web/content/docs/dev/notifications/meta.json b/apps/web/content/docs/dev/notifications/meta.json index d0509cd46..f2aa1d688 100644 --- a/apps/web/content/docs/dev/notifications/meta.json +++ b/apps/web/content/docs/dev/notifications/meta.json @@ -13,7 +13,6 @@ "queue-and-scale", "admincp", "---Guides---", - "blog-example", "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 index 29d4d06f7..608509090 100644 --- a/apps/web/content/docs/dev/notifications/migration-and-troubleshooting.mdx +++ b/apps/web/content/docs/dev/notifications/migration-and-troubleshooting.mdx @@ -12,17 +12,16 @@ work - persistent notifications now have a proper home. ## Apply the database migration -The Notifications Center adds seven 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, digest schedule and preferences | -| `core_notification_subscriptions` | Follows and mutes | -| `core_notification_deliveries` | Email sends and their attempts | -| `core_notification_settings` | Installation settings and per-type policy | +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: @@ -151,9 +150,11 @@ 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: -- the **Background worker** card in - [AdminCP → System → Notifications](/docs/dev/notifications/admincp#health) - says "Not configured" or "Stale" - fix your [cron adapter](/docs/dev/cron); +- 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. @@ -161,24 +162,25 @@ minute. If notifications never arrive, or arrive much later: Check, in this order: -1. An [email adapter](/docs/dev/email) is configured - the **Notification - email** card says "Ready". -2. **Send notification emails** is on in the installation settings. -3. The type's policy has **Allow email** on. -4. The user picked an email mode for the type - the default may be `none`. -5. The **deliveries** list: `failed` shows a sanitized error, and `skipped` - shows a reason such as `empty` (everything was already read). +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 -Open **AdminCP → System → Notifications → Maintenance** and click **Check -unread counts**. If anyone differs, **Fix unread counts** recalculates them 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. +`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" @@ -189,9 +191,10 @@ Users can still read or archive it. Plugins can tidy these up with ### The digest came at the wrong time -Digests follow the user's time zone from `/settings/notifications`. Without -one, the time zone of their language is used, then UTC. The default hour is -08:00, and weekly digests go out on Monday. The scheduler runs every 5 minutes, +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 diff --git a/apps/web/content/docs/dev/notifications/notification-types.mdx b/apps/web/content/docs/dev/notifications/notification-types.mdx index 0c684fc69..98b26b969 100644 --- a/apps/web/content/docs/dev/notifications/notification-types.mdx +++ b/apps/web/content/docs/dev/notifications/notification-types.mdx @@ -1,6 +1,6 @@ --- title: Notification types -description: Reference for buildNotificationType and buildNotificationSubject - ids, schema versions, presentation, access checks, grouping, email and mandatory types. +description: Reference for buildNotificationType - ids, schema versions, presentation, access checks, grouping, email and mandatory types. icon: Shapes --- @@ -8,8 +8,6 @@ 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. -A subject is something users can follow or mute, such as a forum topic or a -blog category. ## Declare a type @@ -47,7 +45,7 @@ export const topicReplyNotification = buildNotificationType({ 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 and subjects +## Register types Pass them to `buildApiPlugin()`: @@ -57,7 +55,6 @@ export const forumApiPlugin = () => pluginId: CONFIG_PLUGIN.pluginId, messages: apiMessages, notificationTypes: [topicReplyNotification], - notificationSubjects: [forumTopicSubject], modules: [topicsModule], }) ``` @@ -70,13 +67,12 @@ export const topicsModule = buildModule({ name: 'topics', routes: [replyRoute], notificationTypes: [topicReplyNotification], // [!code ++] - notificationSubjects: [forumTopicSubject], // [!code ++] }) ``` -Core collects both lists from every installed plugin when the API starts. The -same type id or subject type registered twice - by one plugin or by two - -stops startup with an error naming both owners. +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 @@ -149,7 +145,7 @@ stops startup with an error naming both owners. }, mandatory: { description: - 'Account and security messages: always in-app, cannot be muted or changed by the user.', + 'Account and security messages: always in-app, cannot be changed by the user.', type: 'boolean', default: 'false', }, @@ -292,52 +288,6 @@ 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. -## Declare a subject - -A subject is anything users can follow or mute. Following puts a user in the -audience whenever a publisher names the subject in `followersOf`. Muting -silences every notification about it. - -```ts title="plugins/forum/src/api/lib/notifications.ts" -import { buildNotificationSubject } from '@vitnode/core/api/lib/notifications/registry' - -export const forumTopicSubject = buildNotificationSubject({ - type: 'forum.topic', - canFollow: async ({ c, id, userId }) => - (await canReadTopic(c, Number(id), [userId])).length > 0, - resolveLabels: async ({ c, ids }) => await loadTopicTitles(c, ids), -}) -``` - -<TypeTable - type={{ - type: { - description: - 'Subject type such as "forum.topic". Lowercase segments separated by dots, at most 100 characters.', - type: 'string', - required: true, - }, - canFollow: { - description: - 'Whether userId may follow subject id - usually whether they can see it. Not called for mute, unmute or unfollow.', - type: '(args: { c, id: string, userId: number }) => boolean | Promise<boolean>', - }, - followable: { - description: 'Set false for subjects that can only be muted.', - type: 'boolean', - default: 'true', - }, - resolveLabels: { - description: - 'Human-readable names for the follow and mute lists on the settings page. Ids it leaves out show as raw ids.', - type: '(args: { c, ids: string[], locale: string }) => Record<string, string> | Promise<Record<string, string>>', - }, - }} -/> - -A type's `subjectType` that no plugin declared becomes a **mute-only** subject -automatically, so users can always mute whatever a notification is about. - ## Mandatory types ```ts @@ -345,7 +295,7 @@ mandatory: true, ``` Use `mandatory` for account and security notices users must not miss in the -inbox: always delivered in-app, never muted, and fixed on the preferences page. +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). diff --git a/apps/web/content/docs/dev/notifications/preferences.mdx b/apps/web/content/docs/dev/notifications/preferences.mdx index c76520e61..f8e11523f 100644 --- a/apps/web/content/docs/dev/notifications/preferences.mdx +++ b/apps/web/content/docs/dev/notifications/preferences.mdx @@ -1,15 +1,37 @@ --- title: Preferences -description: How VitNode decides which notifications each user receives in-app and by email - installation policy, mandatory types, mutes, user choices, digest schedule and time zone. +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 they get it in-app and how they -get it by email. Core builds the preferences page at `/settings/notifications` +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. Cards grouped by category ("Content", "Social", "System"); each row shows a type label, a one-line description, an "In-app" switch and an "Email" select with None, Immediately, Daily digest and Weekly digest. Below, a "Digest schedule" card with time zone, hour and weekday selects, and a "Following and muted" card listing "Blog: Releases" with an Unfollow button. */} +{/* 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. + +<Callout title="Push is coming"> + 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. +</Callout> ## How core decides @@ -18,30 +40,33 @@ 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 muted the subject** → nothing. -4. **The user's own choice** for the type - skipped when the type is locked +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)). -5. **The installation default** for the type, set in the +4. **The installation default** for the type, set in the [AdminCP](/docs/dev/notifications/admincp). -6. **The type's own `defaults`.** +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`. -In-app and email are resolved separately. A user can take a type by email only, -in-app only, both or neither. +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 four allow it: +A type can email only when all three allow it: - the type declares `email`; - an [email adapter](/docs/dev/email) is configured; -- notification email is switched on in the AdminCP; -- the type's policy does not set "allow email" off. +- 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`. @@ -56,8 +81,7 @@ administrator changed its email default to `weekly`. | Never opened preferences | yes | weekly - installation default | | Chose `immediate` | yes | immediate - their choice | | Turned in-app off | no | weekly - email is independent | -| Muted the topic | no | none | -| Any user, after "allow email" off | yes | none | +| Any user, after email is Disabled | yes | none | ## What users can change @@ -66,56 +90,54 @@ administrator changed its email default to `weekly`. ```json { "types": { - "forum.topic_reply": { "inApp": true, "email": "weekly" } - }, - "timeZone": "Europe/Warsaw", - "digestHour": 8, - "digestWeekday": 1 + "forum.topic_reply": { "inApp": true, "push": false, "email": "weekly" } + } } ``` -The route answers `400` for an unknown type, a change to a mandatory type, an -email mode for a type that cannot email, or an unknown time zone. Choices are +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. -## Digest schedule and time zone - -| Setting | Values | Default | -| --------------- | ----------------------------- | ---------------------------------------------- | -| `timeZone` | Any IANA name, or `null` | The user's language time zone, then UTC | -| `digestHour` | `0`-`23` | `8` - daily and weekly digests go out at 08:00 | -| `digestWeekday` | `0` (Sunday) - `6` (Saturday) | `1` - weekly digests go out on Monday | +## Digests -Digest periods are calendar days and weeks in that time zone. See +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). -## Follow and mute +## Time zone -Each user has one state per subject: `following`, `muted` or none. Muting a -followed subject unfollows it, and following a muted one unmutes it. +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. -- **Follow** adds the user to every audience that names the subject in - `followersOf`. The subject's `canFollow` must agree. -- **Mute** silences every notification about the subject - in-app and email - - even when the user was named in `recipients`. +```http +PUT /api/@vitnode/core/users/me/time-zone +Content-Type: application/json + +{ "timeZone": "Europe/Warsaw" } +``` -Users follow and mute with [`FollowSubjectButton`](/docs/dev/notifications/inbox-and-realtime#frontend-pieces) -on plugin pages, and unfollow or unmute from `/settings/notifications`. Plugins -can do the same from code with -[`follow()` and `mute()`](/docs/dev/notifications/publishing#follow-and-mute-from-code). +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, cannot be -muted, and shows as fixed on the preferences page. Its email follows the +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. <Callout type="warn" title="Account email is not a notification"> Password resets, email verification and sign-in codes stay on - `c.get("email").send()`. They never pass through notification preferences, the - global email switch or digests, so a user who turned every notification off - still receives them. + `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. </Callout> ## Related diff --git a/apps/web/content/docs/dev/notifications/publishing.mdx b/apps/web/content/docs/dev/notifications/publishing.mdx index ec7292482..bbbdfb6d0 100644 --- a/apps/web/content/docs/dev/notifications/publishing.mdx +++ b/apps/web/content/docs/dev/notifications/publishing.mdx @@ -1,6 +1,6 @@ --- title: Publish notifications -description: Publish a notification from a VitNode plugin with c.get("notifications").publish() - inside your transaction, with an idempotency key, to named recipients or followers of a subject. +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 --- @@ -22,8 +22,7 @@ await c.get('db').transaction(async (tx) => { await c.get('notifications').publish({ type: topicReplyNotification, tx, - recipients: [topic.authorId], - followersOf: [{ type: 'forum.topic', id: topic.id }], + recipients: [topic.authorId, ...topic.participantIds], subject: { type: 'forum.topic', id: topic.id }, data: { topicId: topic.id, topicTitle: topic.title }, idempotencyKey: `reply:${reply.id}`, @@ -63,14 +62,9 @@ dies right after the commit. 'Candidate user ids chosen by your business rules. At most 100,000.', type: 'readonly number[]', }, - followersOf: { - description: - 'Everyone following any of these subjects is a candidate too. At most 20 subjects.', - type: '{ type: string; id: number | string }[]', - }, subject: { description: - "What the notification is about. Drives muting, grouping and remove(). Must match the type's subjectType.", + "What the notification is about. Drives grouping and remove(). Must match the type's subjectType.", type: '{ type: string; id: number | string }', }, tx: { @@ -114,25 +108,21 @@ administrator sending the same text twice on purpose. ## Pick the audience -There are two ways to name candidates, and you can use both in one call: - -- **`recipients`** - user ids your plugin picked: the topic author, the - mentioned users, the members of a group. -- **`followersOf`** - subjects users follow. Core streams the follower list - itself, in batches, so a topic with 50,000 followers costs your code nothing. +`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 who is in both lists, or follows two of the subjects, gets the -notification once. Candidates are only candidates. Core still drops: +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 muted the `subject`; - 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` and no `followersOf` is stored as completed and -delivers nothing. +A publish with no `recipients` is stored as completed and delivers nothing. ### Actor and `allowSelf` @@ -163,8 +153,7 @@ actor or `null` yourself. - `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 20 `followersOf` subjects or more than 100,000 - `recipients`. +- 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. @@ -232,28 +221,14 @@ 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. -## Follow and mute from code - -The model exposes the same follow and mute state the UI uses: +## Read a user's unread count ```ts -const notifications = c.get('notifications') -const subject = { type: 'forum.topic', id: topic.id } - -await notifications.follow(user.id, subject) -await notifications.mute(user.id, subject) -await notifications.unmute(user.id, subject) -await notifications.unfollow(user.id, subject) - -const state = await notifications.subscription(user.id, subject) -// "following" | "muted" | "none" +const { unread, revision } = await c.get('notifications').state(user.id) ``` -A typical use is auto-following a topic for its author when it is created. -`follow()` checks the subject's `canFollow` and throws a 403 when it refuses, -or a 400 for an unknown or mute-only subject type. -`unfollow()` and `unmute()` only clear the matching state. `state(userId)` -returns a user's `{ unread, revision }`. +`revision` goes up with every change, so a client can tell a fresh count from +a stale one. ## Related @@ -266,5 +241,4 @@ returns a user's `{ unread, revision }`. title="Inbox and realtime" href="/docs/dev/notifications/inbox-and-realtime" /> - <Card title="Blog example" href="/docs/dev/notifications/blog-example" /> </Cards> diff --git a/apps/web/content/docs/dev/notifications/queue-and-scale.mdx b/apps/web/content/docs/dev/notifications/queue-and-scale.mdx index 99984f8e2..7bd9d3786 100644 --- a/apps/web/content/docs/dev/notifications/queue-and-scale.mdx +++ b/apps/web/content/docs/dev/notifications/queue-and-scale.mdx @@ -24,7 +24,7 @@ All three belong to `@vitnode/core` and show up in | Cron | Schedule | Does | | ------------------------ | ------------- | -------------------------------------------------------------------------------------------------- | | `notifications-schedule` | `*/5 * * * *` | Reclaims stale email sends, re-queues stalled events, plans digests, queues the email drain if due | -| `notifications-cleanup` | `17 3 * * *` | Queues the daily retention cleanup at 03:17 | +| `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). @@ -32,14 +32,14 @@ in the background - see [Troubleshooting](/docs/dev/notifications/migration-and- ## Batched fan-out The fan-out task works through candidates in batches of `fanoutBatchSize` -(default 500, from 10 to 5,000 - see [Configuration](#configuration)). Named -`recipients` come first, then each `followersOf` subject's followers, streamed -in user id order. +(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, mutes, one `access` call; +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. @@ -52,7 +52,7 @@ 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-follower announcement never blocks the queue for other +the rest, so a 100,000-recipient announcement never blocks the queue for other work. ## Recovery @@ -67,16 +67,15 @@ work. ## 1,000 recipients, measured The integration test `fanout.load.integration.test.ts` publishes one event to -1,000 candidates: 600 followers of a subject plus 601 named recipients, 400 of -them in both lists, one follower who muted the subject, and the actor among the -named ones. +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 muted follower and the actor got none | -| `publish()` | 8 ms | -| Fan-out with two workers racing on the event | 531 ms | -| SQL queries, batch size 200 | 103 in total for 999 recipients | +| 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 | @@ -96,14 +95,13 @@ inbox changes happen one at a time. 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_subscriptions` | Streaming followers of a subject in user id order | -| `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 | +| 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 @@ -125,7 +123,7 @@ 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"; +import { buildApiConfig } from '@vitnode/core/vitnode.config' export const vitNodeApiConfig = buildApiConfig({ // ... @@ -135,27 +133,27 @@ export const vitNodeApiConfig = buildApiConfig({ 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 | +| 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. <Callout title="When to change them"> - 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 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. </Callout> The AdminCP shows the retention period in the diff --git a/apps/web/content/docs/dev/working-with-users/users.mdx b/apps/web/content/docs/dev/working-with-users/users.mdx index ee777fa42..446ccf82c 100644 --- a/apps/web/content/docs/dev/working-with-users/users.mdx +++ b/apps/web/content/docs/dev/working-with-users/users.mdx @@ -107,6 +107,7 @@ The canonical user table contains: | `avatarId` | `integer` | Uploaded avatar, a `core_files` row (nullable) | | `coverId` | `integer` | Uploaded profile cover, a `core_files` row (nullable) | | `language` | `varchar(32)` | Preferred UI locale (defaults to `en`) | +| `timeZone` | `varchar(64)` | IANA time zone, set by the user (nullable) | | `emailVerified` | `boolean` | Verification status | Every user payload the API hands out - the session, the public profile, the @@ -140,6 +141,22 @@ numbers are written with (`+`, spaces, `-`, `(` and `)`), up to 32 characters. Anything else is rejected by the form and by the route. It is **not** on the public profile payload - only the account's own session carries it. +### Time zone + +`timeZone` sits under **Region** on **Settings → Overview**. It takes any IANA +name, like `Europe/Warsaw`, and **Use this device's time zone** fills it in from +the browser in one click. `null` means "follow my language", then UTC. +Notification digests go out in this time zone, and the session carries it, so +`c.get("user")?.timeZone` is there for anything else that wants local time. + +| Method | Path | Who | +| :----- | :-------------------------------------- | :----------------------------------- | +| `PUT` | `/api/@vitnode/core/users/me/time-zone` | The signed-in user, on their own row | + +The body is `{ timeZone: string | null }`. An unknown name answers `400`. +Saving invalidates the cached session and emits +[`user.updated`](/docs/dev/events/built-in-events). + ### Which name the public sees `showRealName` decides whether a profile is headed by the nickname (`name`) or by diff --git a/apps/web/src/locales/@vitnode/blog/pl.json b/apps/web/src/locales/@vitnode/blog/pl.json index 14549aa5e..226e2d983 100644 --- a/apps/web/src/locales/@vitnode/blog/pl.json +++ b/apps/web/src/locales/@vitnode/blog/pl.json @@ -54,8 +54,7 @@ }, "title": "Blog", "post": { - "published": "Opublikowano", - "follow_category": "Chcesz wiedzieć o nowych artykułach w tej kategorii?" + "published": "Opublikowano" } }, "@vitnode/blog:categories": "Kategorie", diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 3f47d0d03..224836a05 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -937,7 +937,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", @@ -1160,43 +1168,65 @@ }, "notifications": { "title": "Powiadomienia", - "desc": "Wybierz, co do Ciebie trafia i czy pojawia się w skrzynce, od razu w e-mailu, czy w codziennym lub cotygodniowym podsumowaniu.", - "in_app": "W aplikacji", - "email": "E-mail", - "email_unavailable": "Bez e-maila", - "mandatory": "Wymagane", - "saved": "Zapisano preferencje", - "saved_desc": "Nowe powiadomienia będą zgodne z nowymi ustawieniami.", + "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 <email></email>.", + "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 <email></email> 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": "Bez e-maila", - "immediate": "E-mail od razu", + "none": "Wyłączone", + "immediate": "Od razu", "daily": "Podsumowanie dzienne", "weekly": "Podsumowanie tygodniowe" - }, - "schedule": { - "title": "Harmonogram podsumowań", - "desc": "Kiedy wysyłamy podsumowania dzienne i tygodniowe, w Twojej strefie czasowej.", - "time_zone": "Strefa czasowa", - "time_zone_desc": "Podsumowania trzymają się tego zegara, łącznie ze zmianą czasu.", - "hour": "Wysyłaj o", - "hour_desc": "O tej godzinie wychodzą podsumowania dzienne.", - "weekday": "Dzień podsumowania tygodniowego", - "weekday_desc": "W tym dniu, o tej samej godzinie, wychodzą podsumowania tygodniowe.", - "submit": "Zapisz harmonogram" - }, - "subscriptions": { - "title": "Obserwowane i wyciszone", - "desc": "Obserwowane rzeczy dodają powiadomień, wyciszone milkną.", - "empty": "Niczego jeszcze nie obserwujesz ani nie wyciszasz.", - "following": "Obserwujesz", - "muted": "Wyciszone", - "unfollow": "Przestań obserwować", - "unmute": "Wyłącz wyciszenie", - "unfollowed": "Nie obserwujesz", - "unmuted": "Wyciszenie wyłączone", - "error": "Nie udało się tego zmienić. Spróbuj ponownie." - }, - "locked": "Ustawione przez administratora" + } } }, "sign_in": { @@ -1750,19 +1780,7 @@ "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.", - "follow": { - "follow": "Obserwuj", - "following": "Obserwujesz", - "mute": "Wycisz powiadomienia", - "unmute": "Wyłącz wyciszenie", - "followed": "Obserwujesz", - "muted": "Wyciszono", - "cleared": "Powiadomienia jak zwykle", - "following_desc": "Dostaniesz powiadomienie o nowej aktywności.", - "muted_desc": "Nie dostaniesz już powiadomień na ten temat.", - "none_desc": "Znów obowiązują Twoje zwykłe ustawienia powiadomień." - } + "action_failed": "Nie udało się. Powiadomienia zostały odświeżone." } }, "navigation": { diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts index b360c67ce..53ea66444 100644 --- a/packages/vitnode/src/api/lib/module.ts +++ b/packages/vitnode/src/api/lib/module.ts @@ -6,10 +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, - NotificationSubjectDefinition, -} from "./notifications/registry"; +import type { AnyNotificationTypeDefinition } from "./notifications/registry"; import type { BuildQueueTaskReturn } from "./queue"; import type { Route } from "./route"; import type { BuildWebSocketReturn } from "./websocket"; @@ -32,7 +29,6 @@ export interface BaseBuildModuleReturn< hono: OpenAPIHono; modules?: BaseBuildModuleReturn<P>[]; name: M; - notificationSubjects?: NotificationSubjectDefinition[]; notificationTypes?: AnyNotificationTypeDefinition[]; pluginId: P; publicContentTypes?: AnyContentTypeDefinition[]; @@ -63,7 +59,6 @@ export function buildModule< modules, contentModels, contentTypes, - notificationSubjects, notificationTypes, publicContentTypes, cronJobs = [], @@ -78,7 +73,6 @@ export function buildModule< events?: BuildEventListenerReturn[]; modules?: Modules; name: M; - notificationSubjects?: NotificationSubjectDefinition[]; notificationTypes?: AnyNotificationTypeDefinition[]; pluginId: P; publicContentTypes?: AnyContentTypeDefinition[]; @@ -111,7 +105,6 @@ export function buildModule< contentTypes, cronJobs, events, - notificationSubjects, notificationTypes, publicContentTypes, queueTasks, diff --git a/packages/vitnode/src/api/lib/notifications/preferences.test.ts b/packages/vitnode/src/api/lib/notifications/preferences.test.ts index f6be4c093..7fb3d89c2 100644 --- a/packages/vitnode/src/api/lib/notifications/preferences.test.ts +++ b/packages/vitnode/src/api/lib/notifications/preferences.test.ts @@ -27,29 +27,23 @@ const resolve = ( resolveNotificationChannels({ definition: type, emailConfigured: true, - muted: false, ...args, }); describe("resolveNotificationChannels", () => { it("falls back from the user, to the installation, to the type", () => { - expect(resolve({})).toEqual({ email: "daily", inApp: true }); + 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 }); - }); - - it("lets a mute beat the user's own preferences", () => { - expect( - resolve({ muted: true, preference: { email: "immediate", inApp: true } }), - ).toEqual({ email: "none", inApp: false }); + ).toEqual({ email: "immediate", inApp: true, push: true }); }); it("lets installation policy beat everything but mandatory types", () => { @@ -58,14 +52,14 @@ describe("resolveNotificationChannels", () => { ).toEqual({ email: "none", inApp: false, + push: false, }); expect( resolve({ definition: mandatory, - muted: true, policy: { enabled: false }, }), - ).toEqual({ email: "daily", inApp: true }); + ).toEqual({ email: "daily", inApp: true, push: true }); }); it("only offers email where the type, installation and policy all allow it", () => { @@ -83,6 +77,7 @@ describe("resolveNotificationChannels", () => { expect(resolve({ preference: { inApp: false } })).toEqual({ email: "daily", inApp: false, + push: true, }); }); @@ -92,7 +87,7 @@ describe("resolveNotificationChannels", () => { policy: { email: "weekly", inApp: false, memberCanEdit: false }, preference: { email: "immediate", inApp: true }, }), - ).toEqual({ email: "weekly", inApp: false }); + ).toEqual({ email: "weekly", inApp: false, push: true }); }); it("keeps a type out of the notification list when in-app is disabled", () => { @@ -101,9 +96,22 @@ describe("resolveNotificationChannels", () => { policy: { allowInApp: false }, preference: { inApp: true }, }), - ).toEqual({ email: "daily", inApp: false }); + ).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 index 91fb2a0e9..27facb862 100644 --- a/packages/vitnode/src/api/lib/notifications/preferences.ts +++ b/packages/vitnode/src/api/lib/notifications/preferences.ts @@ -19,19 +19,12 @@ export interface NotificationTypePolicy { } export interface NotificationGlobalSettings { - digestHour: number; - digestWeekday: number; - emailCapPerHour: number; - /** Master switch for notification email - account email is unaffected. */ - emailEnabled: boolean; paused: boolean; } +export const NOTIFICATION_DIGEST_SCHEDULE = { hour: 8, weekday: 1 } as const; + export const DEFAULT_NOTIFICATION_SETTINGS: NotificationGlobalSettings = { - digestHour: 8, - digestWeekday: 1, - emailCapPerHour: 0, - emailEnabled: true, paused: false, }; @@ -79,9 +72,10 @@ export const resolveNotificationWorkerSettings = ( export interface NotificationChannels { email: NotificationEmailMode; inApp: boolean; + push: boolean; } -const OFF: NotificationChannels = { email: "none", inApp: false }; +const OFF: NotificationChannels = { email: "none", inApp: false, push: false }; /** Whether this type can email at all on this installation. */ export const isNotificationInAppAvailable = ({ @@ -92,6 +86,10 @@ export const isNotificationInAppAvailable = ({ policy?: NotificationTypePolicy; }): boolean => !!definition.mandatory || policy?.allowInApp !== false; +export const isNotificationPushAvailable = ( + policy?: NotificationTypePolicy, +): boolean => policy?.allowPush !== false; + export const isNotificationLockedForMembers = ({ definition, policy, @@ -117,10 +115,9 @@ export const isNotificationEmailAvailable = ({ * * 1. Installation policy disables the type → nothing (mandatory types excepted). * 2. Mandatory types → always in-app; email follows the installation default. - * 3. The user muted the subject → nothing. - * 4. The user's own choice for the type, unless the type is locked for members. - * 5. The installation default for the type. - * 6. The type's own default. + * 3. The user's own choice for the type, unless the type is locked for members. + * 4. The installation default for the type. + * 5. The type's own default. * * Email is additionally limited to what the type and installation offer, and * never depends on the in-app choice. @@ -128,13 +125,11 @@ export const isNotificationEmailAvailable = ({ export const resolveNotificationChannels = ({ definition, emailConfigured, - muted, policy, preference, }: { definition: AnyNotificationTypeDefinition; emailConfigured: boolean; - muted: boolean; policy?: NotificationTypePolicy; preference?: NotificationTypePreference; }): NotificationChannels => { @@ -147,12 +142,16 @@ export const resolveNotificationChannels = ({ }); const defaultEmail = policy?.email ?? definition.defaults.email; + const pushAvailable = isNotificationPushAvailable(policy); + if (definition.mandatory) { - return { email: emailAvailable ? defaultEmail : "none", inApp: true }; + return { + email: emailAvailable ? defaultEmail : "none", + inApp: true, + push: pushAvailable, + }; } - if (muted) return OFF; - const choice = isNotificationLockedForMembers({ definition, policy }) ? undefined : preference; @@ -162,5 +161,6 @@ export const resolveNotificationChannels = ({ 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 index da2d01fdc..36856f843 100644 --- a/packages/vitnode/src/api/lib/notifications/registry.test.ts +++ b/packages/vitnode/src/api/lib/notifications/registry.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { - buildNotificationSubject, buildNotificationType, createNotificationRegistry, parseStoredNotificationData, @@ -65,31 +64,6 @@ describe("createNotificationRegistry", () => { ]), ).toThrow(/Duplicate notification type "blog.comment"/); }); - - it("refuses a subject registered twice", () => { - const subject = buildNotificationSubject({ type: "blog.category" }); - - expect(() => - createNotificationRegistry( - [], - [ - { definition: subject, pluginId: "@acme/a" }, - { definition: subject, pluginId: "@acme/b" }, - ], - ), - ).toThrow(/Duplicate notification subject/); - }); - - it("makes a type's undeclared subject mutable but not followable", () => { - const registry = createNotificationRegistry([ - { - definition: { ...comment, subjectType: "blog.post" }, - pluginId: "@acme/a", - }, - ]); - - expect(registry.getSubject("blog.post")?.definition.followable).toBe(false); - }); }); describe("parseStoredNotificationData", () => { diff --git a/packages/vitnode/src/api/lib/notifications/registry.ts b/packages/vitnode/src/api/lib/notifications/registry.ts index 82b5ef684..8a984d818 100644 --- a/packages/vitnode/src/api/lib/notifications/registry.ts +++ b/packages/vitnode/src/api/lib/notifications/registry.ts @@ -117,56 +117,6 @@ export interface RegisteredNotificationType { pluginId: string; } -/** - * Something users can follow or mute, such as a blog category. Following adds - * a user to the audience whenever a publisher names the subject in - * `followersOf`; muting silences every notification about it. - */ -export interface NotificationSubjectDefinition { - /** - * Whether `userId` may follow subject `id` - typically "can they see it". - * Not called for mutes and unfollows, which only ever reduce what a user - * receives. - */ - canFollow?: (args: { - c: Context<EnvVitNode>; - id: string; - userId: number; - }) => boolean | Promise<boolean>; - /** `false` for subjects that can only be muted. */ - followable?: boolean; - /** - * Human-readable names for the settings page's follow and mute lists, e.g. - * category titles. Ids it leaves out show as their raw id. - */ - resolveLabels?: (args: { - c: Context<EnvVitNode>; - ids: string[]; - locale: string; - }) => Promise<Record<string, string>> | Record<string, string>; - type: string; -} - -export interface RegisteredNotificationSubject { - definition: NotificationSubjectDefinition; - pluginId: string; -} - -export function buildNotificationSubject( - definition: NotificationSubjectDefinition, -): NotificationSubjectDefinition { - if ( - !SUBJECT_TYPE_PATTERN.test(definition.type) || - definition.type.length > 100 - ) { - throw new NotificationRegistryError( - `Invalid notification subject type "${definition.type}".`, - ); - } - - return definition; -} - export type NotificationDataOf<T> = T extends NotificationTypeDefinition<infer D> ? D : never; @@ -285,48 +235,18 @@ export const notificationTypeKey = (pluginId: string, type: string): string => export interface NotificationRegistry { get: (type: string) => RegisteredNotificationType | undefined; - getSubject: (type: string) => RegisteredNotificationSubject | undefined; list: RegisteredNotificationType[]; - subjects: RegisteredNotificationSubject[]; } -/** - * Builds the installation-wide registry. A type's `subjectType` that no plugin - * declared becomes a mute-only subject, so anything a notification is about - * can always be muted. - */ export const createNotificationRegistry = ( entries: readonly RegisteredNotificationType[], - subjects: readonly RegisteredNotificationSubject[] = [], ): NotificationRegistry => { const list = validateNotificationTypes(entries); const byId = new Map(list.map(entry => [entry.definition.id, entry])); - const bySubject = new Map<string, RegisteredNotificationSubject>(); - for (const subject of subjects) { - const owner = bySubject.get(subject.definition.type); - if (owner) { - throw new NotificationRegistryError( - `Duplicate notification subject "${subject.definition.type}": registered by both "${owner.pluginId}" and "${subject.pluginId}".`, - ); - } - bySubject.set(subject.definition.type, subject); - } - for (const entry of list) { - const type = entry.definition.subjectType; - if (type && !bySubject.has(type)) { - bySubject.set(type, { - definition: { followable: false, type }, - pluginId: entry.pluginId, - }); - } - } - return { get: type => byId.get(type), - getSubject: type => bySubject.get(type), list, - subjects: [...bySubject.values()], }; }; diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index 7f75eae10..d9b27f2cb 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -21,10 +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, - NotificationSubjectDefinition, -} from "./notifications/registry"; +import type { AnyNotificationTypeDefinition } from "./notifications/registry"; import type { PermissionStaffConfig } from "./permission-staff"; import type { QueueTaskConfig } from "./queue"; import type { WebSocketConfig } from "./websocket"; @@ -53,7 +50,6 @@ export interface BuildPluginApiReturn< messages?: LocaleMessagesMap; modules: Modules; navigation?: NavigationPresetDeclaration[]; - notificationSubjects?: NotificationSubjectDefinition[]; notificationTypes?: AnyNotificationTypeDefinition[]; openApiTags?: string[]; permissionStaff?: PermissionStaffConfig; @@ -79,7 +75,6 @@ export function buildApiPlugin< messages, modules = [] as unknown as Modules, navigation, - notificationSubjects, notificationTypes, permissionStaff, searchIndexers, @@ -89,8 +84,6 @@ export function buildApiPlugin< messages?: LocaleMessagesMap; modules?: Modules; navigation?: NavigationPresetDeclaration[]; - /** Subjects users can follow or mute; see `buildNotificationSubject`. */ - notificationSubjects?: NotificationSubjectDefinition[]; /** Notification types this plugin publishes; see `buildNotificationType`. */ notificationTypes?: AnyNotificationTypeDefinition[]; permissionStaff?: PermissionStaffConfig; @@ -123,9 +116,6 @@ export function buildApiPlugin< const events: BuildPluginApiReturn["events"] = []; const indexers: SearchIndexer[] = [...(searchIndexers ?? [])]; const types: AnyNotificationTypeDefinition[] = [...(notificationTypes ?? [])]; - const subjects: NotificationSubjectDefinition[] = [ - ...(notificationSubjects ?? []), - ]; const openApiTags: string[] = []; const queueTasks: BuildPluginApiReturn["queueTasks"] = []; const webSockets: BuildPluginApiReturn["webSockets"] = []; @@ -139,7 +129,6 @@ export function buildApiPlugin< publicContentTypes.push(...collectPublicContentTypes(handler)); indexers.push(...collectSearchIndexers(handler)); types.push(...collectModuleTree(handler, m => m.notificationTypes)); - subjects.push(...collectModuleTree(handler, m => m.notificationSubjects)); handler.cronJobs?.forEach(cron => { cronJobs.push({ ...cron, module: handler.name }); @@ -168,7 +157,6 @@ export function buildApiPlugin< // that made the mistake; the global middleware re-checks across plugins. createNotificationRegistry( types.map(definition => ({ definition, pluginId })), - subjects.map(definition => ({ definition, pluginId })), ); const publishing = new Map<string, AnyContentTypeDefinition>(); @@ -196,7 +184,6 @@ export function buildApiPlugin< cronJobs, events, queueTasks, - notificationSubjects: subjects, notificationTypes: types, searchIndexers: indexers, webSockets, diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 6e07e31ae..bc56bc9b8 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -103,6 +103,7 @@ export interface EnvVariablesVitNode { phone: null | string; roleId: number; showRealName: boolean; + timeZone: null | string; }; }; adminSessionExpiresAt: Date | null; @@ -200,6 +201,7 @@ export interface EnvVariablesVitNode { phone: null | string; roleId: number; showRealName: boolean; + timeZone: null | string; }; } @@ -387,12 +389,6 @@ export const globalMiddleware = ({ pluginId: plugin.pluginId, })), ), - plugins.flatMap(plugin => - (plugin.notificationSubjects ?? []).map(definition => ({ - definition, - pluginId: plugin.pluginId, - })), - ), ); const passkeysMetadata = resolvePasskeysConfig({ diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 83e21ed83..a25b70867 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -42,9 +42,6 @@ export interface VitNodeEvents { "notifications.resumed": { requeuedEvents: number; }; - "notifications.settings.updated": { - keys: string[]; - }; "notifications.type.updated": { typeId: string; }; diff --git a/packages/vitnode/src/api/models/notifications/admin.ts b/packages/vitnode/src/api/models/notifications/admin.ts index 65261fdb3..e50276a0c 100644 --- a/packages/vitnode/src/api/models/notifications/admin.ts +++ b/packages/vitnode/src/api/models/notifications/admin.ts @@ -1,10 +1,7 @@ import { and, count, desc, eq, inArray, like, lt, sql } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; -import type { - NotificationGlobalSettings, - NotificationTypePolicy, -} from "@/api/lib/notifications/preferences"; +import type { NotificationTypePolicy } from "@/api/lib/notifications/preferences"; import { isNotificationEmailAvailable, @@ -31,12 +28,9 @@ import { createTranslatorCache } from "./render"; import { getNotificationRegistry, getNotificationWorkers, - GLOBAL_SETTINGS_KEY, isEmailConfigured, loadNotificationSettings, - normalizeGlobalSettings, NOTIFICATIONS_PLUGIN_ID, - QUEUE_NOTIFICATIONS_CLEANUP, typeSettingsKey, } from "./shared"; @@ -53,7 +47,7 @@ export const getNotificationsOverview = async (c: NotificationsContext) => { const db = c.get("db"); const core = c.get("core"); const settings = await loadNotificationSettings(db); - const emailConfigured = isEmailConfigured(c, settings); + const emailConfigured = isEmailConfigured(c); const { t } = await createTranslatorCache(c)(c.get("admin")?.user.language); const [ @@ -113,9 +107,7 @@ export const getNotificationsOverview = async (c: NotificationsContext) => { db .select({ customized: count() }) .from(core_notification_user_state) - .where( - sql`${core_notification_user_state.preferences} <> '{}'::jsonb OR ${core_notification_user_state.digestHour} IS NOT NULL OR ${core_notification_user_state.digestWeekday} IS NOT NULL`, - ), + .where(sql`${core_notification_user_state.preferences} <> '{}'::jsonb`), ]); return { @@ -127,7 +119,6 @@ export const getNotificationsOverview = async (c: NotificationsContext) => { }, email: { adapterConfigured: !!core.email?.adapter, - enabled: settings.global.emailEnabled, }, health: { cronActive: core.hasCronAdapter, @@ -191,20 +182,6 @@ const upsertSetting = async ( }); }; -export const updateNotificationGlobalSettings = async ( - c: NotificationsContext, - patch: Partial<NotificationGlobalSettings>, -): Promise<NotificationGlobalSettings> => { - const { global } = await loadNotificationSettings(c.get("db")); - const next = normalizeGlobalSettings({ ...global, ...patch }); - await upsertSetting(c, GLOBAL_SETTINGS_KEY, { ...next }); - await c.get("events").emit("notifications.settings.updated", { - keys: Object.keys(patch), - }); - - return next; -}; - const POLICY_KEYS = [ "allowEmail", "allowInApp", @@ -336,8 +313,7 @@ export const queueNotificationTestEmail = async ( c: NotificationsContext, adminUserId: number, ): Promise<{ deliveryId: number }> => { - const settings = await loadNotificationSettings(c.get("db")); - if (!isEmailConfigured(c, settings)) { + if (!isEmailConfigured(c)) { throw new HTTPException(400, { message: "Notification email is not configured.", }); @@ -420,10 +396,3 @@ export const reconcileNotifications = async ( return { corrected, mismatched: mismatches.length }; }; - -export const queueNotificationCleanup = async (c: NotificationsContext) => { - await c.get("queue").dispatch({ - name: QUEUE_NOTIFICATIONS_CLEANUP, - pluginId: NOTIFICATIONS_PLUGIN_ID, - }); -}; diff --git a/packages/vitnode/src/api/models/notifications/danger.integration.test.ts b/packages/vitnode/src/api/models/notifications/danger.integration.test.ts index e83d21c29..033efa81a 100644 --- a/packages/vitnode/src/api/models/notifications/danger.integration.test.ts +++ b/packages/vitnode/src/api/models/notifications/danger.integration.test.ts @@ -17,10 +17,7 @@ import { } from "@/tests/notifications"; import { describePostgres } from "@/tests/postgres"; -import { - updateNotificationGlobalSettings, - updateNotificationTypePolicy, -} from "./admin"; +import { updateNotificationTypePolicy } from "./admin"; import { cancelQueuedNotificationEmails, deleteAllNotifications, @@ -29,6 +26,7 @@ import { resetMemberNotificationPreferences, resumeNotifications, } from "./danger"; +import { processNotificationEvent } from "./fanout"; import { getNotificationState } from "./inbox"; import { getNotificationPreferences, @@ -134,6 +132,43 @@ describePostgres("notification admin actions", () => { }); }); + 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); @@ -154,45 +189,44 @@ describePostgres("notification admin actions", () => { ); }); - it("holds immediate emails over the hourly cap, then cancels what is still queued", async () => { + it("cancels every queued email and pending digest, and keeps the inbox", async () => { const [user] = await h.createUsers(1); - await updateNotificationGlobalSettings(h.c, { emailCapPerHour: 1 }); - await publish(alertType, user); - await publish(alertType, user); - await publish(newsType, user); - await settle(); - - const deliveries = await deliveriesOf(user); - expect(deliveries.filter(row => row.status === "sent")).toHaveLength(1); - const held = deliveries.find(row => row.status === "pending"); - expect(held?.availableAt.getTime()).toBeGreaterThan( - Date.now() + 50 * 60 * 1000, - ); - expect(held?.attempts).toBe(0); + 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 [other] = await h.createUsers(1); - await publish(alertType, other); - await settle(); - expect((await deliveriesOf(other)).map(row => row.status)).toEqual([ - "sent", - ]); + const [queued] = await deliveriesOf(user); + expect(queued?.status).toBe("pending"); const { cancelled } = await cancelQueuedNotificationEmails(h.c); expect(cancelled).toBeGreaterThanOrEqual(1); - const after = await deliveriesOf(user); - expect(after.find(row => row.id === held?.id)).toMatchObject({ + expect((await deliveriesOf(user))[0]).toMatchObject({ skipReason: "cancelled", status: "skipped", }); - const pendingReceipts = await h.c + const receipts = await h.c .get("db") .select() .from(core_notification_receipts) .where(eq(core_notification_receipts.userId, user)); - expect(pendingReceipts.every(row => !row.emailPending)).toBe(true); - - await updateNotificationGlobalSettings(h.c, { emailCapPerHour: 0 }); + 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 () => { @@ -213,20 +247,28 @@ describePostgres("notification admin actions", () => { }); 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).toBeGreaterThanOrEqual(7); - expect(stats.totals.sent).toBeGreaterThanOrEqual(2); + 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, { - digestHour: 20, types: { "test.news": { inApp: false } }, }); @@ -239,8 +281,6 @@ describePostgres("notification admin actions", () => { .where(eq(core_notification_user_state.userId, user)); expect(members).toBeGreaterThanOrEqual(1); expect(state).toMatchObject({ - digestHour: null, - digestWeekday: null, preferences: {}, }); }); diff --git a/packages/vitnode/src/api/models/notifications/danger.ts b/packages/vitnode/src/api/models/notifications/danger.ts index 745d541a0..92ebf0659 100644 --- a/packages/vitnode/src/api/models/notifications/danger.ts +++ b/packages/vitnode/src/api/models/notifications/danger.ts @@ -212,14 +212,10 @@ export const resetMemberNotificationPreferences = async ( .get("db") .update(core_notification_user_state) .set({ - digestHour: null, - digestWeekday: null, preferences: sql`'{}'::jsonb`, updatedAt: new Date(), }) - .where( - sql`${core_notification_user_state.preferences} <> '{}'::jsonb OR ${core_notification_user_state.digestHour} IS NOT NULL OR ${core_notification_user_state.digestWeekday} IS NOT NULL`, - ) + .where(sql`${core_notification_user_state.preferences} <> '{}'::jsonb`) .returning({ userId: core_notification_user_state.userId }); await c diff --git a/packages/vitnode/src/api/models/notifications/email-cap.test.ts b/packages/vitnode/src/api/models/notifications/email-cap.test.ts deleted file mode 100644 index f9f76de43..000000000 --- a/packages/vitnode/src/api/models/notifications/email-cap.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { splitByHourlyEmailCap } from "./email-cap"; - -const now = new Date("2026-10-04T12:00:00Z"); - -describe("splitByHourlyEmailCap", () => { - it("sends everything when there is no cap", () => { - const result = splitByHourlyEmailCap({ - cap: 0, - deliveries: [ - { id: 1, mode: "immediate", userId: 7 }, - { id: 2, mode: "immediate", userId: 7 }, - ], - now, - sentInLastHour: new Map([[7, [now, now, now]]]), - }); - - expect(result.allowed).toEqual([1, 2]); - expect(result.deferred.size).toBe(0); - }); - - it("holds immediate emails over the cap until the oldest one leaves the hour", () => { - const result = splitByHourlyEmailCap({ - cap: 2, - deliveries: [ - { id: 1, mode: "immediate", userId: 7 }, - { id: 2, mode: "immediate", userId: 7 }, - { id: 3, mode: "immediate", userId: 8 }, - ], - now, - sentInLastHour: new Map([[7, [new Date("2026-10-04T11:20:00Z")]]]), - }); - - expect(result.allowed).toEqual([1, 3]); - expect(result.deferred.get(2)).toEqual(new Date("2026-10-04T12:20:00Z")); - }); - - it("never holds back digests", () => { - const result = splitByHourlyEmailCap({ - cap: 1, - deliveries: [{ id: 1, mode: "daily", userId: 7 }], - now, - sentInLastHour: new Map([[7, [now]]]), - }); - - expect(result.allowed).toEqual([1]); - }); -}); diff --git a/packages/vitnode/src/api/models/notifications/email-cap.ts b/packages/vitnode/src/api/models/notifications/email-cap.ts deleted file mode 100644 index 87d21a076..000000000 --- a/packages/vitnode/src/api/models/notifications/email-cap.ts +++ /dev/null @@ -1,46 +0,0 @@ -const HOUR = 60 * 60 * 1000; - -export interface CappedDelivery { - id: number; - mode: string; - userId: number; -} - -export const splitByHourlyEmailCap = ({ - cap, - deliveries, - now, - sentInLastHour, -}: { - cap: number; - deliveries: CappedDelivery[]; - now: Date; - sentInLastHour: Map<number, Date[]>; -}): { allowed: number[]; deferred: Map<number, Date> } => { - const allowed: number[] = []; - const deferred = new Map<number, Date>(); - const used = new Map<number, Date[]>(); - - for (const delivery of deliveries) { - if (cap === 0 || delivery.mode !== "immediate") { - allowed.push(delivery.id); - continue; - } - - const window = used.get(delivery.userId) ?? [ - ...(sentInLastHour.get(delivery.userId) ?? []), - ]; - used.set(delivery.userId, window); - - if (window.length < cap) { - window.push(now); - allowed.push(delivery.id); - continue; - } - - const oldest = window.reduce((min, date) => (date < min ? date : min), now); - deferred.set(delivery.id, new Date(oldest.getTime() + HOUR)); - } - - return { allowed, deferred }; -}; diff --git a/packages/vitnode/src/api/models/notifications/email.integration.test.ts b/packages/vitnode/src/api/models/notifications/email.integration.test.ts index f8497a17b..799d8c065 100644 --- a/packages/vitnode/src/api/models/notifications/email.integration.test.ts +++ b/packages/vitnode/src/api/models/notifications/email.integration.test.ts @@ -7,9 +7,9 @@ import { buildNotificationType } from "@/api/lib/notifications/registry"; import { core_notification_deliveries, core_notification_receipts, - core_notification_user_state, core_notifications, } from "@/database/notifications"; +import { core_users } from "@/database/users"; import { createNotificationsHarness, type NotificationsHarness, @@ -168,12 +168,9 @@ describePostgres("notification email", () => { const [user] = await h.createUsers(1); await h.c .get("db") - .insert(core_notification_user_state) - .values({ digestHour: 8, timeZone: "Europe/Warsaw", userId: user }) - .onConflictDoUpdate({ - set: { digestHour: 8, timeZone: "Europe/Warsaw" }, - target: core_notification_user_state.userId, - }); + .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"); diff --git a/packages/vitnode/src/api/models/notifications/email.ts b/packages/vitnode/src/api/models/notifications/email.ts index d5d1bfff4..0eeaeab35 100644 --- a/packages/vitnode/src/api/models/notifications/email.ts +++ b/packages/vitnode/src/api/models/notifications/email.ts @@ -1,18 +1,20 @@ -import { and, asc, eq, gt, inArray, isNull, lt, lte, sql } from "drizzle-orm"; +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 { resolveNotificationChannels } from "@/api/lib/notifications/preferences"; +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_subscriptions, core_notification_user_state, core_notifications, } from "@/database/notifications"; @@ -29,7 +31,6 @@ import type { NotificationSettingsSnapshot, } from "./shared"; -import { splitByHourlyEmailCap } from "./email-cap"; import { dispatchEmailDrain } from "./email-queue"; import { valuesList } from "./fanout"; import { @@ -61,66 +62,24 @@ const absoluteUrl = (target: null | string): null | string => const preferencesUrl = () => new URL("/settings/notifications", CONFIG.web).href; -const mutedKey = (userId: number, type: string, id: string) => - `${userId}|${type}|${id}`; - -const loadMuted = async ( - c: NotificationsContext, - userIds: number[], -): Promise<Set<string>> => { - if (userIds.length === 0) return new Set(); - const rows = await c - .get("db") - .select({ - subjectId: core_notification_subscriptions.subjectId, - subjectType: core_notification_subscriptions.subjectType, - userId: core_notification_subscriptions.userId, - }) - .from(core_notification_subscriptions) - .where( - and( - inArray(core_notification_subscriptions.userId, userIds), - eq(core_notification_subscriptions.state, "muted"), - ), - ); - - return new Set( - rows.map(row => mutedKey(row.userId, row.subjectType, row.subjectId)), - ); -}; - -const isMuted = ( - muted: Set<string>, - userId: number, - event: Pick<NotificationEventRow, "subjectId" | "subjectType">, -) => - !!event.subjectType && - !!event.subjectId && - muted.has(mutedKey(userId, event.subjectType, event.subjectId)); - /** The email mode the user would get for this event right now. */ const currentEmailMode = ({ c, event, - muted, preferences, settings, - userId, }: { c: NotificationsContext; event: NotificationEventRow; - muted: Set<string>; preferences: Record<string, NotificationTypePreference>; settings: NotificationSettingsSnapshot; - userId: number; }): NotificationEmailMode => { const resolved = resolveEvent(c, event); if (!resolved) return "none"; return resolveNotificationChannels({ definition: resolved.registered.definition, - emailConfigured: isEmailConfigured(c, settings), - muted: isMuted(muted, userId, event), + emailConfigured: isEmailConfigured(c), policy: settings.policies.get(event.type), preference: preferences[event.type], }).email; @@ -230,7 +189,7 @@ const deliverNotificationEmail = async ( .where(eq(core_users.id, delivery.userId)) .limit(1); if (!user) return { reason: "user_missing", status: "skipped" }; - if (!isEmailConfigured(c, settings)) { + if (!isEmailConfigured(c)) { return { reason: "email_disabled", status: "skipped" }; } @@ -294,7 +253,6 @@ const deliverNotificationEmail = async ( if (claimed.length === 0) return { reason: "empty", status: "skipped" }; - const muted = await loadMuted(c, [user.id]); const read = await isAlreadyRead(c, claimed); const preferences = user.preferences ?? {}; const dropped: number[] = []; @@ -305,10 +263,8 @@ const deliverNotificationEmail = async ( const mode = currentEmailMode({ c, event: receipt.event, - muted, preferences, settings, - userId: user.id, }); if (mode !== delivery.mode) { @@ -444,74 +400,6 @@ const runWithConcurrency = async <T>( * In-app delivery never waits on this: email runs in its own queue task, so a * slow or failing provider only delays email. */ -const holdOverHourlyCap = async ( - c: NotificationsContext, - claimed: DeliveryRow[], - settings: NotificationSettingsSnapshot, - now: Date, -): Promise<DeliveryRow[]> => { - const cap = settings.global.emailCapPerHour; - const userIds = [ - ...new Set( - claimed.filter(row => row.mode === "immediate").map(row => row.userId), - ), - ]; - if (cap === 0 || userIds.length === 0) return claimed; - - const db = c.get("db"); - const recent = await db - .select({ - sentAt: core_notification_deliveries.sentAt, - userId: core_notification_deliveries.userId, - }) - .from(core_notification_deliveries) - .where( - and( - eq(core_notification_deliveries.mode, "immediate"), - eq(core_notification_deliveries.status, "sent"), - inArray(core_notification_deliveries.userId, userIds), - gt( - core_notification_deliveries.sentAt, - new Date(now.getTime() - 60 * 60 * 1000), - ), - ), - ); - - const sentInLastHour = new Map<number, Date[]>(); - for (const row of recent) { - if (!row.sentAt) continue; - sentInLastHour.set(row.userId, [ - ...(sentInLastHour.get(row.userId) ?? []), - row.sentAt, - ]); - } - - const { deferred } = splitByHourlyEmailCap({ - cap, - deliveries: claimed, - now, - sentInLastHour, - }); - - for (const [id, availableAt] of deferred) { - await db - .update(core_notification_deliveries) - .set({ - attempts: sql`greatest(${core_notification_deliveries.attempts} - 1, 0)`, - availableAt, - status: "pending", - updatedAt: new Date(), - }) - .where( - and( - eq(core_notification_deliveries.id, id), - eq(core_notification_deliveries.status, "sending"), - ), - ); - } - - return claimed.filter(row => !deferred.has(row.id)); -}; export const drainNotificationEmails = async ( c: NotificationsContext, @@ -564,10 +452,9 @@ export const drainNotificationEmails = async ( const stats = { claimed: claimed.length, failed: 0, sent: 0, skipped: 0 }; const translator = createTranslatorCache(c); - const sendable = await holdOverHourlyCap(c, claimed, settings, now); await runWithConcurrency( - sendable, + claimed, getNotificationWorkers(c).emailConcurrency, async delivery => { let patch: Partial<DeliveryRow>; @@ -709,14 +596,12 @@ export const planNotificationDigests = async ( cursor = userIds.at(-1) ?? cursor; users += userIds.length; - const [profiles, receipts, muted] = await Promise.all([ + const [profiles, receipts] = await Promise.all([ db .select({ - digestHour: core_notification_user_state.digestHour, - digestWeekday: core_notification_user_state.digestWeekday, languageTimeZone: core_languages.timezone, preferences: core_notification_user_state.preferences, - timeZone: core_notification_user_state.timeZone, + timeZone: core_users.timeZone, userId: core_users.id, }) .from(core_users) @@ -744,7 +629,6 @@ export const planNotificationDigests = async ( isNull(core_notification_receipts.emailDeliveryId), ), ), - loadMuted(c, userIds), ]); const profileOf = new Map(profiles.map(row => [row.userId, row])); @@ -757,10 +641,8 @@ export const planNotificationDigests = async ( ? currentEmailMode({ c, event: receipt.event, - muted, preferences: profile.preferences ?? {}, settings, - userId: receipt.userId, }) : "none"; @@ -781,11 +663,11 @@ export const planNotificationDigests = async ( } const period = latestEndedDigestPeriod({ - hour: profile.digestHour ?? settings.global.digestHour, + hour: NOTIFICATION_DIGEST_SCHEDULE.hour, mode, now, timeZone: profile.timeZone ?? profile.languageTimeZone ?? "UTC", - weekday: profile.digestWeekday ?? settings.global.digestWeekday, + weekday: NOTIFICATION_DIGEST_SCHEDULE.weekday, }); // Not due yet: it belongs to the period still running. if (receipt.createdAt >= period.end) continue; 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 index c373b9e60..52cef15e3 100644 --- a/packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts +++ b/packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts @@ -3,15 +3,11 @@ import { eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, expect, it } from "vitest"; import { z } from "zod"; -import { - buildNotificationSubject, - buildNotificationType, -} from "@/api/lib/notifications/registry"; +import { buildNotificationType } from "@/api/lib/notifications/registry"; import { core_notification_deliveries, core_notification_events, core_notification_receipts, - core_notification_subscriptions, core_notification_user_state, core_notifications, } from "@/database/notifications"; @@ -44,12 +40,10 @@ const announcementType = buildNotificationType({ present: ({ data }) => ({ title: data.title }), }); -const topicSubject = buildNotificationSubject({ type: "test.topic" }); - /** - * The 1,000-recipient case: half the audience named by the plugin, the rest - * streamed from followers of a subject, with overlap between the two. Timings - * are printed for the record, never asserted - they depend on the machine. + * The 1,000-recipient case, with duplicates in the list and one recipient who + * turned the type off. Timings are printed for the record, never asserted - + * they depend on the machine. */ describePostgres("notification fan-out with 1,000 recipients", () => { let h: NotificationsHarness; @@ -57,35 +51,18 @@ describePostgres("notification fan-out with 1,000 recipients", () => { beforeAll(async () => { h = await createNotificationsHarness({ - subjects: [topicSubject], types: [announcementType], workers: { fanoutBatchSize: BATCH }, }); users = await h.createUsers(RECIPIENTS + 1); - // Users 1-600 follow the topic; 400-1000 are named explicitly (overlap 400-600). await h.c .get("db") - .insert(core_notification_subscriptions) - .values( - users.slice(1, 601).map(userId => ({ - state: "following" as const, - subjectId: "1", - subjectType: "test.topic", - userId, - })), - ); - // One follower muted the topic: they must get nothing. - await h.c - .get("db") - .insert(core_notification_subscriptions) + .insert(core_notification_user_state) .values({ - state: "muted", - subjectId: "1", - subjectType: "test.topic", + preferences: { "test.announcement": { email: "none", inApp: false } }, userId: users[700], - }) - .onConflictDoNothing(); + }); }, 60_000); afterAll(async () => { @@ -100,9 +77,8 @@ describePostgres("notification fan-out with 1,000 recipients", () => { const { eventId } = await h.c.get("notifications").publish({ actorId: actor, data: { title: "Big news" }, - followersOf: [{ id: 1, type: "test.topic" }], idempotencyKey: "announcement-1", - recipients: [actor, ...users.slice(400)], + recipients: [actor, ...users.slice(1), ...users.slice(400, 600)], subject: { id: 1, type: "test.topic" }, type: announcementType, }); @@ -118,7 +94,7 @@ describePostgres("notification fan-out with 1,000 recipients", () => { const fanoutMs = performance.now() - fanoutStarted; const fanoutQueries = h.queryCount() - queriesBefore; - const expected = RECIPIENTS - 1; // everyone but the muted follower; the actor is skipped + const expected = RECIPIENTS - 1; const countRows = async ( table: typeof core_notification_receipts | typeof core_notifications, ) => @@ -148,8 +124,6 @@ describePostgres("notification fan-out with 1,000 recipients", () => { .get("db") .update(core_notification_events) .set({ - followerSubjectCursor: 0, - followerUserCursor: 0, recipientCursor: 0, status: "processing", }) @@ -166,7 +140,7 @@ describePostgres("notification fan-out with 1,000 recipients", () => { }) .from(core_notification_user_state); expect(total).toBe(expected); - expect(wrong).toBe(1); // the muted follower holds a 0 + expect(wrong).toBe(1); const deliveries = await h.c .get("db") @@ -176,7 +150,7 @@ describePostgres("notification fan-out with 1,000 recipients", () => { // Bounded work: the access check ran once per batch, not per recipient, // and the whole fan-out took a fixed number of queries per batch. - const batches = Math.ceil(600 / BATCH) + Math.ceil(601 / BATCH); + const batches = Math.ceil(RECIPIENTS / BATCH); expect(accessCalls).toBeLessThanOrEqual(batches * 2 + 4); expect(fanoutQueries).toBeLessThan(RECIPIENTS); diff --git a/packages/vitnode/src/api/models/notifications/fanout.ts b/packages/vitnode/src/api/models/notifications/fanout.ts index 13b575a93..38b54a643 100644 --- a/packages/vitnode/src/api/models/notifications/fanout.ts +++ b/packages/vitnode/src/api/models/notifications/fanout.ts @@ -1,6 +1,6 @@ import type { SQL } from "drizzle-orm"; -import { and, asc, eq, gt, inArray, 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"; @@ -11,7 +11,6 @@ import { core_notification_deliveries, core_notification_events, core_notification_receipts, - core_notification_subscriptions, core_notification_user_state, core_notifications, } from "@/database/notifications"; @@ -116,64 +115,21 @@ export const applyUnreadDeltas = async ( }; interface Candidates { - cursors: Pick< - EventRow, - "followerSubjectCursor" | "followerUserCursor" | "recipientCursor" - >; done: boolean; + recipientCursor: number; userIds: number[]; } -const nextCandidates = async ( - tx: NotificationsDb, - event: EventRow, - batchSize: number, -): Promise<Candidates> => { - if (event.recipientCursor < event.recipientIds.length) { - const userIds = event.recipientIds.slice( - event.recipientCursor, - event.recipientCursor + batchSize, - ); - const recipientCursor = event.recipientCursor + userIds.length; - - return { - cursors: { ...event, recipientCursor }, - done: - recipientCursor >= event.recipientIds.length && - event.followersOf.length === 0, - userIds, - }; - } - - const subject = event.followersOf[event.followerSubjectCursor]; - if (!subject) return { cursors: event, done: true, userIds: [] }; - - const rows = await tx - .select({ userId: core_notification_subscriptions.userId }) - .from(core_notification_subscriptions) - .where( - and( - eq(core_notification_subscriptions.subjectType, subject.type), - eq(core_notification_subscriptions.subjectId, subject.id), - eq(core_notification_subscriptions.state, "following"), - gt(core_notification_subscriptions.userId, event.followerUserCursor), - ), - ) - .orderBy(asc(core_notification_subscriptions.userId)) - .limit(batchSize); - - const userIds = rows.map(row => row.userId); - const exhausted = userIds.length < batchSize; - const followerSubjectCursor = - event.followerSubjectCursor + (exhausted ? 1 : 0); +const nextCandidates = (event: EventRow, batchSize: number): Candidates => { + const userIds = event.recipientIds.slice( + event.recipientCursor, + event.recipientCursor + batchSize, + ); + const recipientCursor = event.recipientCursor + userIds.length; return { - cursors: { - followerSubjectCursor, - followerUserCursor: exhausted ? 0 : (userIds.at(-1) ?? 0), - recipientCursor: event.recipientCursor, - }, - done: exhausted && followerSubjectCursor >= event.followersOf.length, + done: recipientCursor >= event.recipientIds.length, + recipientCursor, userIds, }; }; @@ -208,7 +164,7 @@ const filterRecipients = async ({ const notSelf = candidates.filter( id => event.allowSelf || id !== event.actorId, ); - if (notSelf.length === 0) return { eligible: [], muted: new Set<number>() }; + if (notSelf.length === 0) return []; const db = c.get("db"); const existing = await db @@ -222,22 +178,6 @@ const filterRecipients = async ({ ? { id: event.subjectId, type: event.subjectType } : null; - const muted = new Set<number>(); - if (subject && eligible.length > 0) { - const rows = await db - .select({ userId: core_notification_subscriptions.userId }) - .from(core_notification_subscriptions) - .where( - and( - eq(core_notification_subscriptions.subjectType, subject.type), - eq(core_notification_subscriptions.subjectId, subject.id), - eq(core_notification_subscriptions.state, "muted"), - inArray(core_notification_subscriptions.userId, eligible), - ), - ); - rows.forEach(row => muted.add(row.userId)); - } - const { access } = registered.definition; if (access && eligible.length > 0) { const allowed = new Set( @@ -246,7 +186,7 @@ const filterRecipients = async ({ eligible = eligible.filter(id => allowed.has(id)); } - return { eligible, muted }; + return eligible; }; const processBatch = async ( @@ -284,12 +224,11 @@ const processBatch = async ( return { delivered: 0, done: true, states: [] }; } - const candidates = await nextCandidates( - tx, + const candidates = nextCandidates( event, getNotificationWorkers(c).fanoutBatchSize, ); - const { eligible, muted } = await filterRecipients({ + const eligible = await filterRecipients({ c, candidates: candidates.userIds, data, @@ -302,7 +241,7 @@ const processBatch = async ( let delivered = 0; const locked = await lockUserStates(tx, eligible); - const emailConfigured = isEmailConfigured(c, settings); + const emailConfigured = isEmailConfigured(c); const policy = settings.policies.get(definition.id); const channels = new Map( locked.map(row => [ @@ -310,7 +249,6 @@ const processBatch = async ( resolveNotificationChannels({ definition, emailConfigured, - muted: muted.has(row.userId), policy, preference: row.preferences[definition.id], }), @@ -480,10 +418,10 @@ const processBatch = async ( await tx .update(core_notification_events) .set({ - ...candidates.cursors, 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)); diff --git a/packages/vitnode/src/api/models/notifications/index.ts b/packages/vitnode/src/api/models/notifications/index.ts index 789047ad3..08751ac62 100644 --- a/packages/vitnode/src/api/models/notifications/index.ts +++ b/packages/vitnode/src/api/models/notifications/index.ts @@ -9,12 +9,10 @@ import type { PublishNotificationResult, } from "./publish"; import type { NotificationsContext } from "./shared"; -import type { NotificationSubscriptionState } from "./subscriptions"; import { getNotificationState, removeNotificationItems } from "./inbox"; import { publishNotification } from "./publish"; import { runQueuedTasksNow } from "./run-now"; -import { getSubscriptionState, setSubscriptionState } from "./subscriptions"; export type { PublishNotificationArgs, PublishNotificationResult }; @@ -50,14 +48,6 @@ export class NotificationsModel { }); } - async follow(userId: number, subject: NotificationSubject): Promise<void> { - await setSubscriptionState(this.c, userId, subject, "following"); - } - - async mute(userId: number, subject: NotificationSubject): Promise<void> { - await setSubscriptionState(this.c, userId, subject, "muted"); - } - /** * Durably records an event and queues its delivery. With `tx` it joins the * producer's transaction, so a rolled-back write never notifies anyone and a @@ -114,23 +104,4 @@ export class NotificationsModel { async state(userId: number) { return await getNotificationState(this.c.get("db"), userId); } - - async subscription( - userId: number, - subject: NotificationSubject, - ): Promise<NotificationSubscriptionState> { - return await getSubscriptionState(this.c, userId, subject); - } - - async unfollow(userId: number, subject: NotificationSubject): Promise<void> { - if ((await this.subscription(userId, subject)) === "following") { - await setSubscriptionState(this.c, userId, subject, "none"); - } - } - - async unmute(userId: number, subject: NotificationSubject): Promise<void> { - if ((await this.subscription(userId, subject)) === "muted") { - await setSubscriptionState(this.c, userId, subject, "none"); - } - } } diff --git a/packages/vitnode/src/api/models/notifications/preferences.ts b/packages/vitnode/src/api/models/notifications/preferences.ts index c3c524d7b..1ed3f3872 100644 --- a/packages/vitnode/src/api/models/notifications/preferences.ts +++ b/packages/vitnode/src/api/models/notifications/preferences.ts @@ -4,11 +4,11 @@ import { HTTPException } from "hono/http-exception"; import type { NotificationTypePreference } from "@/database/notifications"; import type { NotificationEmailMode } from "@/lib/notifications/types"; -import { isValidTimeZone } from "@/api/lib/notifications/digest-period"; import { isNotificationEmailAvailable, isNotificationInAppAvailable, isNotificationLockedForMembers, + isNotificationPushAvailable, resolveNotificationChannels, } from "@/api/lib/notifications/preferences"; import { core_notification_user_state } from "@/database/notifications"; @@ -26,6 +26,7 @@ import { export interface NotificationTypePreferenceView { category: string; categoryLabel: string; + defaultEmail: NotificationEmailMode; description: null | string; /** The modes this type may use here - empty when email is unavailable. */ emailModes: NotificationEmailMode[]; @@ -35,14 +36,12 @@ export interface NotificationTypePreferenceView { locked: boolean; mandatory: boolean; pluginId: string; + pushAvailable: boolean; /** What the user gets now, after defaults and installation policy. */ - value: { email: NotificationEmailMode; inApp: boolean }; + value: { email: NotificationEmailMode; inApp: boolean; push: boolean }; } export interface NotificationPreferencesView { - digestHour: number; - digestWeekday: number; - timeZone: null | string; types: NotificationTypePreferenceView[]; } @@ -77,7 +76,7 @@ export const getNotificationPreferences = async ( .limit(1), ]); const { t } = await createTranslatorCache(c)(language); - const emailConfigured = isEmailConfigured(c, settings); + const emailConfigured = isEmailConfigured(c); const preferences = state?.preferences ?? {}; const types = getNotificationRegistry(c) @@ -108,6 +107,11 @@ export const getNotificationPreferences = async ( ], definition.category, ), + defaultEmail: resolveNotificationChannels({ + definition, + emailConfigured, + policy, + }).email, description: definition.description ? translateOr(t, [definition.description], definition.description) : null, @@ -120,10 +124,10 @@ export const getNotificationPreferences = async ( locked, mandatory: !!definition.mandatory, pluginId, + pushAvailable: isNotificationPushAvailable(policy), value: resolveNotificationChannels({ definition, emailConfigured, - muted: false, policy, preference: preferences[definition.id], }), @@ -131,17 +135,11 @@ export const getNotificationPreferences = async ( }); return { - digestHour: state?.digestHour ?? settings.global.digestHour, - digestWeekday: state?.digestWeekday ?? settings.global.digestWeekday, - timeZone: state?.timeZone ?? null, types, }; }; export interface UpdateNotificationPreferencesArgs { - digestHour?: number; - digestWeekday?: number; - timeZone?: null | string; types?: Record<string, NotificationTypePreference>; } @@ -158,11 +156,7 @@ export const updateNotificationPreferences = async ( const registry = getNotificationRegistry(c); const db = c.get("db"); const settings = await loadNotificationSettings(db); - const emailConfigured = isEmailConfigured(c, settings); - - if (args.timeZone && !isValidTimeZone(args.timeZone)) { - throw new HTTPException(400, { message: "Unknown time zone." }); - } + const emailConfigured = isEmailConfigured(c); const patch: Record<string, NotificationTypePreference> = {}; for (const [typeId, preference] of Object.entries(args.types ?? {})) { @@ -196,9 +190,15 @@ export const updateNotificationPreferences = async ( 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 }), }; } @@ -214,13 +214,6 @@ export const updateNotificationPreferences = async ( .set({ preferences: sql`${JSON.stringify(merged)}::jsonb`, updatedAt: new Date(), - ...(args.timeZone === undefined ? {} : { timeZone: args.timeZone }), - ...(args.digestHour === undefined - ? {} - : { digestHour: args.digestHour }), - ...(args.digestWeekday === undefined - ? {} - : { digestWeekday: args.digestWeekday }), }) .where(eq(core_notification_user_state.userId, userId)); }); diff --git a/packages/vitnode/src/api/models/notifications/publish.ts b/packages/vitnode/src/api/models/notifications/publish.ts index 06e33a510..edfddc942 100644 --- a/packages/vitnode/src/api/models/notifications/publish.ts +++ b/packages/vitnode/src/api/models/notifications/publish.ts @@ -17,9 +17,7 @@ import { QUEUE_NOTIFICATIONS_FANOUT, } from "./shared"; -/** Above this, name the audience with `followersOf` and let core stream it. */ export const MAX_EXPLICIT_RECIPIENTS = 100_000; -const MAX_FOLLOWER_SUBJECTS = 20; export interface PublishNotificationArgs<TData> { /** @@ -33,8 +31,6 @@ export interface PublishNotificationArgs<TData> { */ allowSelf?: boolean; data: NoInfer<TData>; - /** Everyone following any of these subjects is a candidate too. */ - followersOf?: NotificationSubject[]; /** * Stable for one real-world happening, e.g. `comment:42`. Publishing the * same key twice for a type is a no-op that returns the first event. @@ -142,13 +138,6 @@ export const publishNotification = async <TData>( ); } - const followersOf = (args.followersOf ?? []).map(normalizeSubject); - if (followersOf.length > MAX_FOLLOWER_SUBJECTS) { - throw new NotificationPublishError( - `followersOf accepts at most ${MAX_FOLLOWER_SUBJECTS} subjects.`, - ); - } - const actorId = args.actorId === undefined ? (c.get("admin")?.user.id ?? c.get("user")?.id ?? null) @@ -167,12 +156,12 @@ export const publishNotification = async <TData>( if (recipientIds.length > MAX_EXPLICIT_RECIPIENTS) { throw new NotificationPublishError( - `At most ${MAX_EXPLICIT_RECIPIENTS} explicit recipients per event; use followersOf for larger audiences.`, + `At most ${MAX_EXPLICIT_RECIPIENTS} recipients per event.`, ); } const db = args.tx ?? c.get("db"); - const hasAudience = recipientIds.length > 0 || followersOf.length > 0; + const hasAudience = recipientIds.length > 0; const now = new Date(); const [created] = await db @@ -183,7 +172,6 @@ export const publishNotification = async <TData>( completedAt: hasAudience ? null : now, createdAt: now, data: parsed.data as Record<string, unknown>, - followersOf, groupKey: resolveGroupKey(definition, parsed.data, subject), idempotencyKey, pluginId, diff --git a/packages/vitnode/src/api/models/notifications/shared.ts b/packages/vitnode/src/api/models/notifications/shared.ts index 10c0b2596..d920adbed 100644 --- a/packages/vitnode/src/api/models/notifications/shared.ts +++ b/packages/vitnode/src/api/models/notifications/shared.ts @@ -45,34 +45,12 @@ export interface NotificationSettingsSnapshot { policies: Map<string, NotificationTypePolicy>; } -const clampInt = ( - value: unknown, - min: number, - max: number, - fallback: number, -) => - typeof value === "number" && Number.isInteger(value) - ? Math.min(max, Math.max(min, value)) - : fallback; - export const normalizeGlobalSettings = ( value: Record<string, unknown> | undefined, ): NotificationGlobalSettings => { const defaults = DEFAULT_NOTIFICATION_SETTINGS; return { - digestHour: clampInt(value?.digestHour, 0, 23, defaults.digestHour), - digestWeekday: clampInt(value?.digestWeekday, 0, 6, defaults.digestWeekday), - emailCapPerHour: clampInt( - value?.emailCapPerHour, - 0, - 500, - defaults.emailCapPerHour, - ), - emailEnabled: - typeof value?.emailEnabled === "boolean" - ? value.emailEnabled - : defaults.emailEnabled, paused: typeof value?.paused === "boolean" ? value.paused : defaults.paused, }; }; @@ -101,10 +79,8 @@ export const getNotificationWorkers = ( c.get("core").notificationWorkers ?? resolveNotificationWorkerSettings(); /** Whether notification emails can be sent at all on this installation. */ -export const isEmailConfigured = ( - c: NotificationsContext, - settings: NotificationSettingsSnapshot, -): boolean => !!c.get("core").email?.adapter && settings.global.emailEnabled; +export const isEmailConfigured = (c: NotificationsContext): boolean => + !!c.get("core").email?.adapter; /** * Sends each user their committed unread state. Call only after the diff --git a/packages/vitnode/src/api/models/notifications/subscriptions.ts b/packages/vitnode/src/api/models/notifications/subscriptions.ts deleted file mode 100644 index 95bf82776..000000000 --- a/packages/vitnode/src/api/models/notifications/subscriptions.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { and, asc, eq } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; - -import type { NotificationSubject } from "@/lib/notifications/types"; - -import { core_notification_subscriptions } from "@/database/notifications"; - -import type { NotificationsContext } from "./shared"; - -import { getNotificationRegistry } from "./shared"; - -export type NotificationSubscriptionState = "following" | "muted" | "none"; - -const subjectWhere = (userId: number, subject: { id: string; type: string }) => - and( - eq(core_notification_subscriptions.userId, userId), - eq(core_notification_subscriptions.subjectType, subject.type), - eq(core_notification_subscriptions.subjectId, subject.id), - ); - -const normalize = (subject: NotificationSubject) => ({ - id: String(subject.id), - type: subject.type, -}); - -export const getSubscriptionState = async ( - c: NotificationsContext, - userId: number, - subject: NotificationSubject, -): Promise<NotificationSubscriptionState> => { - const [row] = await c - .get("db") - .select({ state: core_notification_subscriptions.state }) - .from(core_notification_subscriptions) - .where(subjectWhere(userId, normalize(subject))) - .limit(1); - - return row?.state ?? "none"; -}; - -/** - * Follows, mutes or clears one subject for one user. Following and muting are - * one state, so muting a followed subject unfollows it and vice versa. Throws - * a 400 for unknown subject types and a 403 when the plugin's `canFollow` - * refuses. - */ -export const setSubscriptionState = async ( - c: NotificationsContext, - userId: number, - subject: NotificationSubject, - state: NotificationSubscriptionState, -): Promise<NotificationSubscriptionState> => { - const normalized = normalize(subject); - const registered = getNotificationRegistry(c).getSubject(normalized.type); - if (!registered || normalized.id.length === 0 || normalized.id.length > 100) { - throw new HTTPException(400, { message: "Unknown notification subject." }); - } - - const db = c.get("db"); - if (state === "none") { - await db - .delete(core_notification_subscriptions) - .where(subjectWhere(userId, normalized)); - - return "none"; - } - - if (state === "following") { - const { canFollow, followable } = registered.definition; - if (followable === false) { - throw new HTTPException(400, { - message: "This subject cannot be followed.", - }); - } - if (canFollow && !(await canFollow({ c, id: normalized.id, userId }))) { - throw new HTTPException(403); - } - } - - await db - .insert(core_notification_subscriptions) - .values({ - createdAt: new Date(), - state, - subjectId: normalized.id, - subjectType: normalized.type, - userId, - }) - .onConflictDoUpdate({ - set: { createdAt: new Date(), state }, - target: [ - core_notification_subscriptions.userId, - core_notification_subscriptions.subjectType, - core_notification_subscriptions.subjectId, - ], - }); - - return state; -}; - -export const listSubscriptions = async ( - c: NotificationsContext, - { - limit, - state, - userId, - }: { - limit: number; - state?: "following" | "muted"; - userId: number; - }, -) => - await c - .get("db") - .select({ - createdAt: core_notification_subscriptions.createdAt, - state: core_notification_subscriptions.state, - subjectId: core_notification_subscriptions.subjectId, - subjectType: core_notification_subscriptions.subjectType, - }) - .from(core_notification_subscriptions) - .where( - and( - eq(core_notification_subscriptions.userId, userId), - state ? eq(core_notification_subscriptions.state, state) : undefined, - ), - ) - .orderBy( - asc(core_notification_subscriptions.subjectType), - asc(core_notification_subscriptions.subjectId), - ) - .limit(limit); 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/notifications/notifications.admin.module.ts b/packages/vitnode/src/api/modules/admin/notifications/notifications.admin.module.ts index da7e7fa51..2ad9600bf 100644 --- a/packages/vitnode/src/api/modules/admin/notifications/notifications.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/notifications/notifications.admin.module.ts @@ -15,14 +15,10 @@ import { } from "./routes/deliveries.route"; import { reconcileNotificationCountsRoute, - runNotificationCleanupRoute, sendNotificationTestEmailRoute, } from "./routes/maintenance.route"; import { getNotificationsOverviewRoute } from "./routes/overview.route"; -import { - updateNotificationSettingsRoute, - updateNotificationTypePolicyRoute, -} from "./routes/settings.route"; +import { updateNotificationTypePolicyRoute } from "./routes/settings.route"; import { getNotificationStatsRoute } from "./routes/stats.route"; export const notificationsAdminModule = buildModule({ @@ -30,13 +26,11 @@ export const notificationsAdminModule = buildModule({ name: "notifications", routes: [ getNotificationsOverviewRoute, - updateNotificationSettingsRoute, updateNotificationTypePolicyRoute, listNotificationDeliveriesRoute, retryNotificationDeliveryRoute, sendNotificationTestEmailRoute, reconcileNotificationCountsRoute, - runNotificationCleanupRoute, getNotificationStatsRoute, pauseNotificationsRoute, resumeNotificationsRoute, 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 index 31a8efa7b..0b1204415 100644 --- a/packages/vitnode/src/api/modules/admin/notifications/routes/maintenance.route.ts +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/maintenance.route.ts @@ -3,22 +3,11 @@ import { z } from "zod"; import { buildRoute } from "@/api/lib/route"; import { - queueNotificationCleanup, queueNotificationTestEmail, reconcileNotifications, } from "@/api/models/notifications/admin"; import { CONFIG_PLUGIN } from "@/config"; -const success = { - 200: { - content: { - "application/json": { schema: z.object({ success: z.boolean() }) }, - }, - description: "Queued", - }, - 403: { description: "Access Denied" }, -} as const; - export const sendNotificationTestEmailRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, adminStaffPermission: { module: "notifications", permission: "can_manage" }, @@ -84,20 +73,3 @@ export const reconcileNotificationCountsRoute = buildRoute({ handler: async c => c.json(await reconcileNotifications(c, c.req.valid("json"))), }); - -export const runNotificationCleanupRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "notifications", permission: "can_manage" }, - route: { - method: "post", - description: - "Queue the retention cleanup now instead of waiting for the nightly run.", - path: "/cleanup", - responses: success, - }, - handler: async c => { - await queueNotificationCleanup(c); - - return c.json({ success: true }); - }, -}); 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 index 4b27cf506..76dbf188e 100644 --- a/packages/vitnode/src/api/modules/admin/notifications/routes/overview.route.ts +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/overview.route.ts @@ -8,15 +8,7 @@ import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types"; const zodEmailMode = z.enum(NOTIFICATION_EMAIL_MODES); const zodCounts = z.record(z.string(), z.number()); -export const zodEditableNotificationSettings = z.object({ - digestHour: z.number().int().min(0).max(23), - digestWeekday: z.number().int().min(0).max(6), - emailCapPerHour: z.number().int().min(0).max(500), - emailEnabled: z.boolean(), -}); - -export const zodNotificationGlobalSettings = - zodEditableNotificationSettings.extend({ paused: z.boolean() }); +export const zodNotificationGlobalSettings = z.object({ paused: z.boolean() }); export const getNotificationsOverviewRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, @@ -39,7 +31,6 @@ export const getNotificationsOverviewRoute = buildRoute({ }), email: z.object({ adapterConfigured: z.boolean(), - enabled: z.boolean(), }), health: z.object({ cronActive: z.boolean(), 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 index ee35c7eee..af18e184b 100644 --- a/packages/vitnode/src/api/modules/admin/notifications/routes/settings.route.ts +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/settings.route.ts @@ -1,50 +1,10 @@ import { z } from "zod"; import { buildRoute } from "@/api/lib/route"; -import { - updateNotificationGlobalSettings, - updateNotificationTypePolicy, -} from "@/api/models/notifications/admin"; +import { updateNotificationTypePolicy } from "@/api/models/notifications/admin"; import { CONFIG_PLUGIN } from "@/config"; import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types"; -import { - zodEditableNotificationSettings, - zodNotificationGlobalSettings, -} from "./overview.route"; - -export const updateNotificationSettingsRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "notifications", permission: "can_edit" }, - route: { - method: "put", - description: - "Update installation-wide notification settings: email switch, hourly email cap and default digest schedule. Retention and worker sizes come from `notifications` in the API config.", - path: "/settings", - request: { - body: { - required: true, - content: { - "application/json": { - schema: zodEditableNotificationSettings.partial(), - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { schema: zodNotificationGlobalSettings }, - }, - description: "Saved settings", - }, - 403: { description: "Access Denied" }, - }, - }, - handler: async c => - c.json(await updateNotificationGlobalSettings(c, c.req.valid("json"))), -}); - export const updateNotificationTypePolicyRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, adminStaffPermission: { module: "notifications", permission: "can_edit" }, diff --git a/packages/vitnode/src/api/modules/notifications/notifications.module.ts b/packages/vitnode/src/api/modules/notifications/notifications.module.ts index 76c0a81b1..ffbca9ec3 100644 --- a/packages/vitnode/src/api/modules/notifications/notifications.module.ts +++ b/packages/vitnode/src/api/modules/notifications/notifications.module.ts @@ -14,11 +14,6 @@ import { updateNotificationPreferencesRoute, } from "./routes/preferences.route"; import { notificationStateRoute } from "./routes/state.route"; -import { - getSubscriptionRoute, - listSubscriptionsRoute, - setSubscriptionRoute, -} from "./routes/subscriptions.route"; import { notificationsCleanupCron, notificationsCleanupTask, @@ -39,9 +34,6 @@ export const notificationsModule = buildModule({ archiveNotificationRoute, getNotificationPreferencesRoute, updateNotificationPreferencesRoute, - listSubscriptionsRoute, - getSubscriptionRoute, - setSubscriptionRoute, ], cronJobs: [notificationsScheduleCron, notificationsCleanupCron], queueTasks: [ diff --git a/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts b/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts index 7526a30a1..ab24d27a1 100644 --- a/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts +++ b/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts @@ -14,13 +14,11 @@ import { } from "../schema"; const zodPreferences = z.object({ - digestHour: z.number(), - digestWeekday: z.number(), - timeZone: z.string().nullable(), types: z.array( z.object({ category: z.string(), categoryLabel: z.string(), + defaultEmail: zodNotificationEmailMode, description: z.string().nullable(), emailModes: z.array(zodNotificationEmailMode), id: z.string(), @@ -29,7 +27,12 @@ const zodPreferences = z.object({ locked: z.boolean(), mandatory: z.boolean(), pluginId: z.string(), - value: z.object({ email: zodNotificationEmailMode, inApp: z.boolean() }), + pushAvailable: z.boolean(), + value: z.object({ + email: zodNotificationEmailMode, + inApp: z.boolean(), + push: z.boolean(), + }), }), ), }); @@ -62,15 +65,13 @@ export const getNotificationPreferencesRoute = buildRoute({ }); export const zodUpdateNotificationPreferences = z.object({ - digestHour: z.number().int().min(0).max(23).optional(), - digestWeekday: z.number().int().min(0).max(6).optional(), - timeZone: z.string().max(64).nullable().optional(), types: z .record( z.string().max(100), z.object({ email: zodNotificationEmailMode.optional(), inApp: z.boolean().optional(), + push: z.boolean().optional(), }), ) .optional(), diff --git a/packages/vitnode/src/api/modules/notifications/routes/subscriptions.route.ts b/packages/vitnode/src/api/modules/notifications/routes/subscriptions.route.ts deleted file mode 100644 index e212f4b91..000000000 --- a/packages/vitnode/src/api/modules/notifications/routes/subscriptions.route.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { z } from "zod"; - -import { buildRoute } from "@/api/lib/route"; -import { getNotificationRegistry } from "@/api/models/notifications/shared"; -import { - getSubscriptionState, - listSubscriptions, - setSubscriptionState, -} from "@/api/models/notifications/subscriptions"; -import { CONFIG_PLUGIN } from "@/config"; - -import { - requireNotificationUser, - unauthorizedResponse, - zodSubscriptionState, -} from "../schema"; - -const zodSubject = z.object({ - subjectId: z.string().min(1).max(100), - subjectType: z.string().min(1).max(100), -}); - -export const getSubscriptionRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - route: { - method: "get", - description: - "Whether the signed-in user follows or muted one subject, e.g. a blog category.", - path: "/subscriptions/{subjectType}/{subjectId}", - request: { params: zodSubject }, - responses: { - 200: { - content: { - "application/json": { - schema: z.object({ state: zodSubscriptionState }), - }, - }, - description: "Subscription state", - }, - ...unauthorizedResponse, - }, - }, - handler: async c => { - const user = requireNotificationUser(c); - const { subjectId, subjectType } = c.req.valid("param"); - - return c.json({ - state: await getSubscriptionState(c, user.id, { - id: subjectId, - type: subjectType, - }), - }); - }, -}); - -export const setSubscriptionRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - route: { - method: "put", - description: - "Follow, mute or clear one subject for the signed-in user. Following asks the owning plugin's canFollow first.", - path: "/subscriptions", - request: { - body: { - required: true, - content: { - "application/json": { - schema: zodSubject.extend({ state: zodSubscriptionState }), - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: z.object({ state: zodSubscriptionState }), - }, - }, - description: "Saved", - }, - 400: { description: "Unknown subject type" }, - 403: { description: "The plugin does not let this user follow it" }, - ...unauthorizedResponse, - }, - }, - handler: async c => { - const user = requireNotificationUser(c); - const { state, subjectId, subjectType } = c.req.valid("json"); - - return c.json({ - state: await setSubscriptionState( - c, - user.id, - { id: subjectId, type: subjectType }, - state, - ), - }); - }, -}); - -export const listSubscriptionsRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - route: { - method: "get", - description: "The subjects the signed-in user follows or muted.", - path: "/subscriptions", - request: { - query: z.object({ state: z.enum(["following", "muted"]).optional() }), - }, - responses: { - 200: { - content: { - "application/json": { - schema: z.object({ - items: z.array( - z.object({ - createdAt: z.date(), - /** A readable name, when the owning plugin provides one. */ - label: z.string().nullable(), - state: z.enum(["following", "muted"]), - subjectId: z.string(), - subjectType: z.string(), - }), - ), - }), - }, - }, - description: "Subscriptions", - }, - ...unauthorizedResponse, - }, - }, - handler: async c => { - const user = requireNotificationUser(c); - const { state } = c.req.valid("query"); - const items = await listSubscriptions(c, { - limit: 200, - state, - userId: user.id, - }); - - const registry = getNotificationRegistry(c); - const locale = c.get("i18n").resolveSupportedLocale(user.language); - const labels = new Map<string, string>(); - for (const type of new Set(items.map(item => item.subjectType))) { - const resolve = registry.getSubject(type)?.definition.resolveLabels; - if (!resolve) continue; - try { - const ids = items - .filter(item => item.subjectType === type) - .map(item => item.subjectId); - const resolved = await resolve({ c, ids, locale }); - for (const [id, label] of Object.entries(resolved)) { - labels.set(`${type}:${id}`, label.slice(0, 200)); - } - } catch { - // A plugin failing to name its subjects only costs the pretty label. - } - } - - return c.json({ - items: items.map(item => ({ - ...item, - label: labels.get(`${item.subjectType}:${item.subjectId}`) ?? null, - })), - }); - }, -}); diff --git a/packages/vitnode/src/api/modules/notifications/tasks/notification-tasks.ts b/packages/vitnode/src/api/modules/notifications/tasks/notification-tasks.ts index 916095c5c..3ce0038bd 100644 --- a/packages/vitnode/src/api/modules/notifications/tasks/notification-tasks.ts +++ b/packages/vitnode/src/api/modules/notifications/tasks/notification-tasks.ts @@ -70,7 +70,7 @@ export const notificationsScheduleCron = buildCron({ export const notificationsCleanupCron = buildCron({ name: "notifications-cleanup", description: "Queue the daily notification retention cleanup.", - schedule: "17 3 * * *", + schedule: "0 2 * * *", handler: async c => { await c.get("queue").dispatch({ name: QUEUE_NOTIFICATIONS_CLEANUP, 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 901b5ec4b..10a7a2615 100644 --- a/packages/vitnode/src/api/modules/users/routes/session.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/session.route.ts @@ -27,6 +27,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(), 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/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 index 44f8391b5..cf472dd51 100644 --- a/packages/vitnode/src/database/notifications.ts +++ b/packages/vitnode/src/database/notifications.ts @@ -7,8 +7,8 @@ import { core_users } from "./users"; /** * One published notification event, stored once however many people receive - * it. `recipientIds` and `followersOf` are the candidates the publisher named; - * the fan-out cursor records how far delivery got, so a retry resumes there. + * it. `recipientIds` are the candidates the publisher named; the fan-out + * cursor records how far delivery got, so a retry resumes there. */ export const core_notification_events = camelCase.table.withRLS( "core_notification_events", @@ -30,11 +30,6 @@ export const core_notification_events = camelCase.table.withRLS( .array() .notNull() .default(sql`'{}'::integer[]`), - followersOf: t - .jsonb() - .$type<{ id: string; type: string }[]>() - .notNull() - .default([]), allowSelf: t.boolean().notNull().default(false), status: t .varchar({ @@ -45,10 +40,6 @@ export const core_notification_events = camelCase.table.withRLS( .default("pending"), /** Index into `recipientIds` processed so far. */ recipientCursor: t.integer().notNull().default(0), - /** Index into `followersOf` currently being streamed. */ - followerSubjectCursor: t.integer().notNull().default(0), - /** Last follower user id processed within that subject. */ - followerUserCursor: t.integer().notNull().default(0), deliveredCount: t.integer().notNull().default(0), lastError: t.text(), createdAt: t.timestamp().notNull().defaultNow(), @@ -162,6 +153,7 @@ export const core_notification_receipts = camelCase.table.withRLS( export interface NotificationTypePreference { email?: NotificationEmailMode; inApp?: boolean; + push?: boolean; } /** @@ -178,10 +170,6 @@ export const core_notification_user_state = camelCase.table.withRLS( .references(() => core_users.id, { onDelete: "cascade" }), unreadCount: t.integer().notNull().default(0), revision: t.bigint({ mode: "number" }).notNull().default(0), - timeZone: t.varchar({ length: 64 }), - digestHour: t.smallint(), - /** 0 = Sunday ... 6 = Saturday. */ - digestWeekday: t.smallint(), preferences: t .jsonb() .$type<Record<string, NotificationTypePreference>>() @@ -192,30 +180,6 @@ export const core_notification_user_state = camelCase.table.withRLS( t => [index("core_notification_user_state_unread_idx").on(t.unreadCount)], ); -/** A user following or muting one subject, such as a blog category. */ -export const core_notification_subscriptions = camelCase.table.withRLS( - "core_notification_subscriptions", - t => ({ - userId: t - .integer() - .references(() => core_users.id, { onDelete: "cascade" }) - .notNull(), - subjectType: t.varchar({ length: 100 }).notNull(), - subjectId: t.varchar({ length: 100 }).notNull(), - state: t.varchar({ enum: ["following", "muted"], length: 20 }).notNull(), - createdAt: t.timestamp().notNull().defaultNow(), - }), - t => [ - primaryKey({ columns: [t.userId, t.subjectType, t.subjectId] }), - index("core_notification_subscriptions_subject_idx").on( - t.subjectType, - t.subjectId, - t.state, - t.userId, - ), - ], -); - /** One email send - immediate, digest or test - and its attempts. */ export const core_notification_deliveries = camelCase.table.withRLS( "core_notification_deliveries", diff --git a/packages/vitnode/src/database/users.ts b/packages/vitnode/src/database/users.ts index 8db037543..2b2a093c0 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/locales/en.json b/packages/vitnode/src/locales/en.json index e1dec8536..28307c6d5 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -510,19 +510,7 @@ "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.", - "follow": { - "follow": "Follow", - "following": "Following", - "mute": "Mute notifications", - "unmute": "Unmute notifications", - "followed": "Following", - "muted": "Muted", - "cleared": "Notifications back to normal", - "following_desc": "You'll be notified about new activity here.", - "muted_desc": "You won't get notifications about this anymore.", - "none_desc": "Your usual notification settings apply again." - } + "action_failed": "That didn't work. Your notifications were refreshed." } }, "navigation": { @@ -828,7 +816,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", @@ -1073,43 +1069,65 @@ }, "notifications": { "title": "Notifications", - "desc": "Choose what reaches you, and whether it arrives in your inbox, by email right away, or in a daily or weekly summary.", - "in_app": "In-app", - "email": "Email", - "email_unavailable": "No email", - "mandatory": "Required", - "saved": "Preferences saved", - "saved_desc": "New notifications will follow your updated settings.", - "email_modes": { - "none": "No email", - "immediate": "Email right away", - "daily": "Daily summary", - "weekly": "Weekly summary" - }, - "schedule": { - "title": "Summary schedule", - "desc": "When daily and weekly summaries are sent, in your time zone.", - "time_zone": "Time zone", - "time_zone_desc": "Summaries follow this clock, daylight saving included.", - "hour": "Send at", - "hour_desc": "Daily summaries go out at this hour.", - "weekday": "Weekly summary day", - "weekday_desc": "Weekly summaries go out on this day, at the same hour.", - "submit": "Save schedule" + "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 <email></email>.", + "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 <email></email>.", + "unavailable": "None of your notifications are sent by email right now.", + "on_toast_desc": "Notification emails are back on." + } }, - "subscriptions": { - "title": "Followed and muted", - "desc": "Things you follow add to what you hear about; muted ones stay quiet.", - "empty": "You aren't following or muting anything yet.", - "following": "Following", - "muted": "Muted", - "unfollow": "Unfollow", - "unmute": "Unmute", - "unfollowed": "Unfollowed", - "unmuted": "Unmuted", - "error": "We couldn't update that. Please try again." + "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." }, - "locked": "Set by the admin" + "email_modes": { + "none": "Off", + "immediate": "Right away", + "daily": "Daily digest", + "weekly": "Weekly digest" + } } } }, @@ -2109,7 +2127,7 @@ "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.}} Muted types, email choices and digest schedules are replaced with the defaults on this page.", + "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", @@ -2118,37 +2136,11 @@ "settings": { "button": "Settings", "title": "Notification settings", - "desc": "Every change saves on its own and applies to everyone.", - "read_only": "You can see these settings, but changing them needs the \"Can edit\" notifications permission.", + "desc": "Tools and actions that reach every member.", "groups": { - "email": "Email", "tools": "Tools", "danger": "Danger zone" }, - "edit": "Edit {label}", - "save": "Save", - "cancel": "Cancel", - "success": "Saved", - "success_desc": "{label}: {value}.", - "on": "On", - "off": "Off", - "emailEnabled": { - "label": "Send notification emails", - "desc": "Off keeps everything in the notification list, whatever members picked." - }, - "emailCapPerHour": { - "label": "Emails per member per hour", - "desc": "Immediate emails over the limit wait until the hour is up. 0 means no limit.", - "none": "No limit" - }, - "digestHour": { - "label": "Daily digest time", - "desc": "For members who haven't picked their own, in their time zone." - }, - "digestWeekday": { - "label": "Weekly digest day", - "desc": "For members who haven't picked their own." - }, "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": { @@ -2160,12 +2152,6 @@ "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." - }, - "cleanup": { - "label": "Run cleanup now", - "desc": "Removes notifications older than {count, plural, one {# day} other {# days}} now instead of tonight.", - "success": "Cleanup started", - "success_desc": "Old notifications are being removed in the background." } }, "danger": { diff --git a/packages/vitnode/src/pages/settings/notifications.tsx b/packages/vitnode/src/pages/settings/notifications.tsx index 90d84f5d5..bf26270f1 100644 --- a/packages/vitnode/src/pages/settings/notifications.tsx +++ b/packages/vitnode/src/pages/settings/notifications.tsx @@ -3,10 +3,7 @@ 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, - notificationSubscriptionsQueryOptions, -} from "@/views/notifications/notifications-query"; +import { notificationPreferencesQueryOptions } from "@/views/notifications/notifications-query"; interface NotificationSettingsData { userId: number; @@ -22,16 +19,10 @@ export const route = defineAuthenticatedRoute<NotificationSettingsData>({ load: async ({ context }) => { const userId = context.auth.user.id; - await Promise.all([ - context.queryClient.query({ - ...notificationPreferencesQueryOptions({ userId }), - staleTime: "static", - }), - context.queryClient.query({ - ...notificationSubscriptionsQueryOptions({ userId }), - staleTime: "static", - }), - ]); + await context.queryClient.query({ + ...notificationPreferencesQueryOptions({ userId }), + staleTime: "static", + }); return { userId }; }, diff --git a/packages/vitnode/src/tanstack/admin/notifications/query.ts b/packages/vitnode/src/tanstack/admin/notifications/query.ts index 291cd7768..2ba26a55d 100644 --- a/packages/vitnode/src/tanstack/admin/notifications/query.ts +++ b/packages/vitnode/src/tanstack/admin/notifications/query.ts @@ -41,14 +41,12 @@ export const useNotificationsAdminActions = (): NotificationsAdminActions => { return { cancelQueuedEmails: refreshing(browser.cancelQueuedEmails), - cleanup: refreshing(browser.cleanup), deleteAll: refreshing(browser.deleteAll), markEverythingRead: refreshing(browser.markEverythingRead), pause: refreshing(browser.pause), resetMemberPreferences: refreshing(browser.resetMemberPreferences), resume: refreshing(browser.resume), sendTestEmail: refreshing(browser.sendTestEmail), - updateSettings: refreshing(browser.updateSettings), updateTypePolicy: refreshing(browser.updateTypePolicy), }; }, [queryClient]); diff --git a/packages/vitnode/src/tanstack/admin/notifications/screen.tsx b/packages/vitnode/src/tanstack/admin/notifications/screen.tsx index 730adbd88..9bd5c924b 100644 --- a/packages/vitnode/src/tanstack/admin/notifications/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/notifications/screen.tsx @@ -34,15 +34,15 @@ export const AdminNotificationsRouteContent = ({ <RouteMessages namespaces={ADMIN_NOTIFICATIONS_NAMESPACES}> <div className="p-6"> <PageTitle desc={description} h1={title}> - <NotificationsSettingsSheet - actions={actions} - canEdit={canEdit} - canManage={canManage} - counts={data.counts} - emailConfigured={data.email.adapterConfigured && data.email.enabled} - settings={data.settings} - workers={data.workers} - /> + {canManage ? ( + <NotificationsSettingsSheet + actions={actions} + counts={data.counts} + emailConfigured={data.email.adapterConfigured} + settings={data.settings} + workers={data.workers} + /> + ) : null} </PageTitle> <NotificationsContent diff --git a/packages/vitnode/src/tanstack/auth/state.test.ts b/packages/vitnode/src/tanstack/auth/state.test.ts index 97f644c9b..35873572d 100644 --- a/packages/vitnode/src/tanstack/auth/state.test.ts +++ b/packages/vitnode/src/tanstack/auth/state.test.ts @@ -35,6 +35,7 @@ const userFixture = (overrides: Partial<AuthUser> = {}): AuthUser => ({ notifications: { revision: 0, unread: 0 }, roleId: 1, showRealName: false, + timeZone: null, ...overrides, }); diff --git a/packages/vitnode/src/tanstack/notifications/follow-subject-button.tsx b/packages/vitnode/src/tanstack/notifications/follow-subject-button.tsx deleted file mode 100644 index dec81242f..000000000 --- a/packages/vitnode/src/tanstack/notifications/follow-subject-button.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { BellOffIcon, BellPlusIcon, BellRingIcon } from "lucide-react"; -import React from "react"; -import { toast } from "sonner"; -import { useTranslations } from "use-intl"; - -import type { NotificationSubject } from "@/lib/notifications/types"; -import type { NotificationSubscriptionState } from "@/views/notifications/notifications-actions"; - -import { Button } from "@/components/ui/button"; -import { ButtonGroup } from "@/components/ui/button-group"; -import { setNotificationSubscriptionInBrowser } from "@/views/notifications/notifications-actions"; -import { - notificationSubscriptionQueryOptions, - notificationSubscriptionsQueryKey, -} from "@/views/notifications/notifications-query"; - -import { useSessionQuery } from "../auth/session-query"; - -/** - * Follow and mute controls for one subject, for any plugin page: - * - * ```tsx - * <FollowSubjectButton subject={{ type: "blog.category", id: category.id }} /> - * ``` - * - * Following adds the user to audiences the plugin publishes with - * `followersOf`; muting silences everything about the subject. The subject - * type must be registered with `buildNotificationSubject`. Guests see nothing. - */ -export const FollowSubjectButton = ({ - canMute = true, - subject, -}: { - /** Hide the mute toggle, e.g. where a subject is only ever followed. */ - canMute?: boolean; - subject: NotificationSubject; -}) => { - const { data: session } = useSessionQuery(); - const userId = session?.user?.id; - if (userId === undefined) return null; - - return ( - <SignedInFollowButton canMute={canMute} subject={subject} userId={userId} /> - ); -}; - -const SignedInFollowButton = ({ - canMute, - subject, - userId, -}: { - canMute: boolean; - subject: NotificationSubject; - userId: number; -}) => { - const t = useTranslations("core.global.notifications.follow"); - const tErrors = useTranslations("core.global.errors"); - const queryClient = useQueryClient(); - const options = notificationSubscriptionQueryOptions({ subject, userId }); - const { data: state, isPending } = useQuery(options); - const [isSaving, setIsSaving] = React.useState(false); - - const set = async (next: NotificationSubscriptionState) => { - setIsSaving(true); - try { - const saved = await setNotificationSubscriptionInBrowser({ - state: next, - subject, - }); - queryClient.setQueryData(options.queryKey, saved); - toast.success( - next === "following" - ? t("followed") - : next === "muted" - ? t("muted") - : t("cleared"), - { description: t(`${next}_desc`) }, - ); - } catch { - toast.error(tErrors("title"), { - description: tErrors("internal_server_error"), - }); - } finally { - setIsSaving(false); - await queryClient.invalidateQueries({ - queryKey: notificationSubscriptionsQueryKey(userId), - refetchType: "none", - }); - } - }; - - const following = state === "following"; - const muted = state === "muted"; - const disabled = isPending || isSaving; - - return ( - <ButtonGroup> - <Button - aria-pressed={following} - disabled={disabled} - onClick={() => void set(following ? "none" : "following")} - size="sm" - variant={following ? "secondary" : "outline"} - > - {following ? <BellRingIcon /> : <BellPlusIcon />} - {following ? t("following") : t("follow")} - </Button> - {canMute ? ( - <Button - aria-label={muted ? t("unmute") : t("mute")} - aria-pressed={muted} - disabled={disabled} - onClick={() => void set(muted ? "none" : "muted")} - size="icon-sm" - title={muted ? t("unmute") : t("mute")} - variant={muted ? "secondary" : "outline"} - > - <BellOffIcon /> - </Button> - ) : null} - </ButtonGroup> - ); -}; diff --git a/packages/vitnode/src/tanstack/notifications/index.ts b/packages/vitnode/src/tanstack/notifications/index.ts index 7984ac062..1e7316dce 100644 --- a/packages/vitnode/src/tanstack/notifications/index.ts +++ b/packages/vitnode/src/tanstack/notifications/index.ts @@ -1,6 +1,5 @@ export { useNotificationActions } from "./actions"; export { NotificationsBell } from "./bell"; -export { FollowSubjectButton } from "./follow-subject-button"; export { NOTIFICATIONS_NAMESPACES } from "./namespaces"; export { NotificationsRouteContent } from "./screen"; export { NotificationSettingsPanel } from "./settings-screen"; diff --git a/packages/vitnode/src/tanstack/notifications/settings-screen.tsx b/packages/vitnode/src/tanstack/notifications/settings-screen.tsx index cb004c7af..453e9ac8d 100644 --- a/packages/vitnode/src/tanstack/notifications/settings-screen.tsx +++ b/packages/vitnode/src/tanstack/notifications/settings-screen.tsx @@ -1,69 +1,32 @@ import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { LockIcon } from "lucide-react"; import React from "react"; import { toast } from "sonner"; -import { useFormatter, useTranslations } from "use-intl"; -import { z } from "zod"; +import { useTranslations } from "use-intl"; -import type { AutoFormOnSubmit } from "@/components/form/auto-form"; import type { NotificationPreferencesView, NotificationPreferenceTypeView, } from "@/views/notifications/notifications-query"; -import { AutoForm } from "@/components/form/auto-form"; -import { AutoFormCombobox } from "@/components/form/fields/combobox"; -import { AutoFormSelect } from "@/components/form/fields/select"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; import { PageTitle } from "@/components/ui/page-title"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { - setNotificationSubscriptionInBrowser, - updateNotificationPreferencesInBrowser, -} from "@/views/notifications/notifications-actions"; +import { updateNotificationPreferencesInBrowser } from "@/views/notifications/notifications-actions"; import { notificationPreferencesQueryKey, notificationPreferencesQueryOptions, - notificationSubscriptionsQueryKey, - notificationSubscriptionsQueryOptions, } 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"; -type EmailMode = NotificationPreferenceTypeView["value"]["email"]; - -const HOURS = Array.from({ length: 24 }, (_, hour) => String(hour)); -const WEEKDAYS = ["0", "1", "2", "3", "4", "5", "6"]; - -const supportedTimeZones = (): string[] => { - try { - return Intl.supportedValuesOf("timeZone"); - } catch { - return ["UTC"]; - } -}; +import { sessionQueryOptions } from "../auth/session-query"; -const browserTimeZone = (): string => { - try { - return Intl.DateTimeFormat().resolvedOptions().timeZone; - } catch { - return "UTC"; - } -}; +type EmailMode = NotificationPreferenceTypeView["value"]["email"]; +type UpdateBody = Parameters<typeof updateNotificationPreferencesInBrowser>[0]; const usePreferenceSaver = (userId: number) => { const queryClient = useQueryClient(); @@ -71,276 +34,57 @@ const usePreferenceSaver = (userId: number) => { const tErrors = useTranslations("core.global.errors"); return React.useCallback( - async ( - body: Parameters<typeof updateNotificationPreferencesInBrowser>[0], - optimistic?: ( - data: NotificationPreferencesView, - ) => NotificationPreferencesView, - ) => { + async (body: UpdateBody, description?: string): Promise<boolean> => { const key = notificationPreferencesQueryKey(userId); - if (optimistic) { - queryClient.setQueryData<NotificationPreferencesView>(key, data => - data ? optimistic(data) : data, - ); - } + queryClient.setQueryData<NotificationPreferencesView>(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: t("saved_desc") }); + 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], ); }; -const PreferenceRow = ({ - onChange, - type, -}: { - onChange: (patch: { email?: EmailMode; inApp?: boolean }) => void; - type: NotificationPreferenceTypeView; -}) => { - const t = useTranslations("core.auth.settings.notifications"); - const switchId = `notification-in-app-${type.id}`; - const emailId = `notification-email-${type.id}`; - const modes = type.emailModes.map(mode => ({ - label: t(`email_modes.${mode}`), - value: mode, - })); - - return ( - <li className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between"> - <div className="flex min-w-0 flex-col gap-1"> - <span className="flex items-center gap-2 font-medium"> - {type.label} - {type.mandatory ? ( - <Badge variant="secondary"> - <LockIcon /> - {t("mandatory")} - </Badge> - ) : type.locked ? ( - <Badge variant="secondary"> - <LockIcon /> - {t("locked")} - </Badge> - ) : null} - </span> - {type.description ? ( - <span className="text-muted-foreground text-sm leading-relaxed text-pretty"> - {type.description} - </span> - ) : null} - </div> - - <div className="flex shrink-0 flex-wrap items-center gap-4"> - {type.inAppAvailable ? ( - <div className="flex items-center gap-2"> - <Switch - checked={type.value.inApp} - disabled={type.locked} - id={switchId} - onCheckedChange={checked => onChange({ inApp: checked })} - /> - <Label htmlFor={switchId}>{t("in_app")}</Label> - </div> - ) : null} - - {modes.length > 0 ? ( - <div className="flex items-center gap-2"> - <Label className="sr-only" htmlFor={emailId}> - {t("email")} - </Label> - <Select - items={modes} - onValueChange={value => { - if (value) onChange({ email: value as EmailMode }); - }} - value={type.value.email} - > - <SelectTrigger className="w-44" id={emailId}> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {modes.map(mode => ( - <SelectItem key={mode.value} value={mode.value}> - {mode.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - ) : ( - <span className="text-muted-foreground text-sm"> - {type.locked && type.value.email !== "none" - ? t(`email_modes.${type.value.email}`) - : t("email_unavailable")} - </span> - )} - </div> - </li> - ); -}; - -const ScheduleForm = ({ - preferences, - userId, -}: { - preferences: NotificationPreferencesView; - userId: number; -}) => { - const t = useTranslations("core.auth.settings.notifications.schedule"); - const format = useFormatter(); - const save = usePreferenceSaver(userId); - const zones = React.useMemo(() => { - const list = supportedTimeZones(); - - return list.includes("UTC") ? list : ["UTC", ...list]; - }, []); - - const formSchema = z.object({ - timeZone: z - .enum(zones as [string, ...string[]]) - .default(preferences.timeZone ?? browserTimeZone()), - digestHour: z - .enum(HOURS as [string, ...string[]]) - .default(String(preferences.digestHour)), - digestWeekday: z - .enum(WEEKDAYS as [string, ...string[]]) - .default(String(preferences.digestWeekday)), - }); - - const onSubmit: AutoFormOnSubmit<typeof formSchema> = async values => { - const digestHour = Number(values.digestHour); - const digestWeekday = Number(values.digestWeekday); - - await save({ - ...(digestHour === preferences.digestHour ? {} : { digestHour }), - ...(digestWeekday === preferences.digestWeekday ? {} : { digestWeekday }), - timeZone: values.timeZone, - }); - }; - - return ( - <AutoForm - fields={[ - { - id: "timeZone", - component: props => ( - <AutoFormCombobox - description={t("time_zone_desc")} - label={t("time_zone")} - {...props} - /> - ), - }, - { - id: "digestHour", - component: props => ( - <AutoFormSelect - description={t("hour_desc")} - label={t("hour")} - labels={HOURS.map(hour => ({ - label: `${hour.padStart(2, "0")}:00`, - value: hour, - }))} - {...props} - /> - ), - }, - { - id: "digestWeekday", - component: props => ( - <AutoFormSelect - description={t("weekday_desc")} - label={t("weekday")} - labels={WEEKDAYS.map(day => ({ - // 7 January 2024 was a Sunday - day 0. - label: format.dateTime( - new Date(Date.UTC(2024, 0, 7 + Number(day))), - { timeZone: "UTC", weekday: "long" }, - ), - value: day, - }))} - {...props} - /> - ), - }, - ]} - formSchema={formSchema} - onSubmit={onSubmit} - submitButtonProps={{ children: t("submit") }} - /> - ); -}; - -const SubscriptionsList = ({ userId }: { userId: number }) => { - const t = useTranslations("core.auth.settings.notifications.subscriptions"); - const queryClient = useQueryClient(); - const { data } = useSuspenseQuery( - notificationSubscriptionsQueryOptions({ userId }), - ); - - const clear = async (item: (typeof data)[number]) => { - try { - await setNotificationSubscriptionInBrowser({ - state: "none", - subject: { id: item.subjectId, type: item.subjectType }, - }); - toast.success(item.state === "muted" ? t("unmuted") : t("unfollowed"), { - description: item.label ?? item.subjectId, - }); - } catch { - toast.error(t("error")); - } - await queryClient.invalidateQueries({ - queryKey: notificationSubscriptionsQueryKey(userId), - }); - }; - - if (data.length === 0) { - return ( - <p className="text-muted-foreground text-sm leading-relaxed"> - {t("empty")} - </p> - ); - } - - return ( - <ul className="divide-y"> - {data.map(item => ( - <li - className="flex items-center justify-between gap-3 py-3" - key={`${item.subjectType}:${item.subjectId}`} - > - <span className="flex min-w-0 flex-col gap-1"> - <span className="truncate font-medium"> - {item.label ?? `${item.subjectType} #${item.subjectId}`} - </span> - <span className="text-muted-foreground text-sm"> - {item.state === "muted" ? t("muted") : t("following")} - </span> - </span> - <Button onClick={() => void clear(item)} size="sm" variant="outline"> - {item.state === "muted" ? t("unmute") : t("unfollow")} - </Button> - </li> - ))} - </ul> - ); -}; - -/** `/settings/notifications`: what reaches the user, where, and when. */ 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 | string>(null); + const [emailBeforeStop, setEmailBeforeStop] = React.useState<null | Record< + string, + EmailMode + >>(null); const groups = [ ...data.types @@ -356,21 +100,28 @@ export const NotificationSettingsPanel = ({ userId }: { userId: number }) => { .values(), ]; - const change = ( - type: NotificationPreferenceTypeView, - patch: { email?: EmailMode; inApp?: boolean }, - ) => - void save({ types: { [type.id]: patch } }, current => ({ - ...current, - types: current.types.map(entry => - entry.id === type.id - ? { ...entry, value: { ...entry.value, ...patch } } - : entry, - ), - })); + const setEmail = (on: boolean) => { + if (on) { + void save( + { types: emailOnPatch(data.types, emailBeforeStop) }, + emailBeforeStop + ? t("channels.email.restored_toast_desc") + : t("channels.email.on_toast_desc"), + ); + setEmailBeforeStop(null); + + return; + } + + setEmailBeforeStop(emailSnapshot(data.types)); + void save( + { types: emailOffPatch(data.types) }, + t("channels.email.stopped_toast_desc"), + ); + }; return ( - <div className="flex flex-col gap-6"> + <div className="flex flex-col gap-8"> <PageTitle className="mb-0" desc={t("desc")} @@ -378,50 +129,53 @@ export const NotificationSettingsPanel = ({ userId }: { userId: number }) => { subtitle={tSettings("title")} /> - {groups.map(group => ( - <Card key={group.label}> - <CardHeader> - <CardTitle> - <h2>{group.label}</h2> - </CardTitle> - </CardHeader> - <CardContent> - <ul className="divide-y"> - {group.types.map(type => ( - <PreferenceRow - key={type.id} - onChange={patch => change(type, patch)} - type={type} - /> - ))} - </ul> - </CardContent> - </Card> - ))} - - <Card> - <CardHeader> - <CardTitle> - <h2>{t("schedule.title")}</h2> - </CardTitle> - <CardDescription>{t("schedule.desc")}</CardDescription> - </CardHeader> - <CardContent> - <ScheduleForm preferences={data} userId={userId} /> - </CardContent> - </Card> + <NotificationChannelsGroup + email={session.user?.email ?? ""} + emailState={emailChannelState(data.types)} + onEmailChange={setEmail} + /> - <Card> - <CardHeader> - <CardTitle> - <h2>{t("subscriptions.title")}</h2> - </CardTitle> - <CardDescription>{t("subscriptions.desc")}</CardDescription> - </CardHeader> - <CardContent> - <SubscriptionsList userId={userId} /> - </CardContent> - </Card> + <section + aria-labelledby="notification-types" + className="flex flex-col gap-2" + > + <h2 + className="text-muted-foreground text-sm font-medium" + id="notification-types" + > + {t("types.title")} + </h2> + {groups.length === 0 ? ( + <p className="text-muted-foreground text-sm">{t("types.empty")}</p> + ) : ( + <ul className="bg-card ring-foreground/10 overflow-hidden rounded-md shadow-xs ring-1"> + {groups.map(group => ( + <React.Fragment key={group.label}> + {groups.length > 1 ? ( + <li className="bg-muted/40 text-muted-foreground border-b px-4 py-2 text-xs font-medium"> + <h3>{group.label}</h3> + </li> + ) : null} + {group.types.map(type => ( + <NotificationTypeItem + isOpen={openId === type.id} + key={type.id} + onChange={patch => { + void save({ types: { [type.id]: patch } }); + }} + onToggle={() => { + setOpenId(current => + current === type.id ? null : type.id, + ); + }} + type={type} + /> + ))} + </React.Fragment> + ))} + </ul> + )} + </section> </div> ); }; 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 ( <OverviewSettingsContent canEditPersonalInfo={policy.canEdit} editor={editor} + onTimeZoneUpdate={onTimeZoneUpdate} onUpdate={onUpdate} personalFields={policy.fields} user={{ @@ -64,6 +77,7 @@ export const OverviewSettings = ({ nameCode }: { nameCode: string }) => { name: session.user.name, phone: session.user.phone, showRealName: session.user.showRealName, + timeZone: session.user.timeZone, }} /> ); diff --git a/packages/vitnode/src/tests/notifications.ts b/packages/vitnode/src/tests/notifications.ts index 39a500cb0..d97de9615 100644 --- a/packages/vitnode/src/tests/notifications.ts +++ b/packages/vitnode/src/tests/notifications.ts @@ -4,10 +4,7 @@ import type { ReactElement } from "react"; import { and, eq, lte } from "drizzle-orm"; import { createTranslator } from "use-intl"; -import type { - AnyNotificationTypeDefinition, - NotificationSubjectDefinition, -} from "@/api/lib/notifications/registry"; +import type { AnyNotificationTypeDefinition } from "@/api/lib/notifications/registry"; import type { EnvVitNode } from "@/api/middlewares/global.middleware"; import type { NotificationEmailProps } from "@/emails/notification"; @@ -76,13 +73,11 @@ const notificationTasks = [ export const createNotificationsHarness = async ({ email = true, messages = {}, - subjects = [], types, workers, }: { email?: boolean; messages?: Record<string, unknown>; - subjects?: NotificationSubjectDefinition[]; types: AnyNotificationTypeDefinition[]; workers?: Partial<NotificationWorkerSettings>; }): Promise<NotificationsHarness> => { @@ -112,7 +107,6 @@ export const createNotificationsHarness = async ({ let emailFailure: Error | null = null; const registry = createNotificationRegistry( types.map(definition => ({ definition, pluginId: "@acme/test" })), - subjects.map(definition => ({ definition, pluginId: "@acme/test" })), ); const translator = createTranslator({ locale: "en", 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 index 3ff360fc0..ac826d5a3 100644 --- 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 @@ -3,11 +3,6 @@ import type { NotificationEmailMode } from "@/lib/notifications/types"; import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; -import type { - AdminNotificationEditableSettings, - AdminNotificationSettings, -} from "./notifications-query"; - /** What every write on this screen resolves to - never a throw. */ export type NotificationsMutationResult<T> = | { data: T; error?: never; status?: never } @@ -28,7 +23,6 @@ export interface NotificationsAdminActions { cancelQueuedEmails: () => Promise< NotificationsMutationResult<{ cancelled: number }> >; - cleanup: () => Promise<NotificationsMutationResult<{ success: boolean }>>; deleteAll: () => Promise< NotificationsMutationResult<{ events: number; items: number }> >; @@ -45,9 +39,6 @@ export interface NotificationsAdminActions { sendTestEmail: () => Promise< NotificationsMutationResult<{ deliveryId: number }> >; - updateSettings: ( - body: Partial<AdminNotificationEditableSettings>, - ) => Promise<NotificationsMutationResult<AdminNotificationSettings>>; updateTypePolicy: ( type: string, body: NotificationTypePolicyPatch, @@ -82,22 +73,6 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { return FAILED; } }, - cleanup: async () => { - try { - const response = await fetcherClient({ - plugin: CONFIG_PLUGIN.pluginId, - method: "post", - module: "admin/notifications", - options, - path: "/cleanup", - }); - if (!response.ok) return await failure(response); - - return { data: await response.json() }; - } catch { - return FAILED; - } - }, deleteAll: async () => { try { const response = await fetcherClient({ @@ -194,23 +169,6 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { return FAILED; } }, - updateSettings: async body => { - try { - const response = await fetcherClient({ - plugin: CONFIG_PLUGIN.pluginId, - args: { body }, - method: "put", - module: "admin/notifications", - options, - path: "/settings", - }); - if (!response.ok) return await failure(response); - - return { data: await response.json() }; - } catch { - return FAILED; - } - }, updateTypePolicy: async (type, body) => { try { const response = await fetcherClient({ 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 index 82781a7fe..6bdc62f2e 100644 --- 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 @@ -9,10 +9,6 @@ import { AdminRequestError } from "@/views/admin/admin-request"; import { adminQueryRoot } from "@/views/admin/table/query"; export interface AdminNotificationSettings { - digestHour: number; - digestWeekday: number; - emailCapPerHour: number; - emailEnabled: boolean; paused: boolean; } @@ -23,11 +19,6 @@ export interface AdminNotificationWorkers { retentionDays: number; } -export type AdminNotificationEditableSettings = Omit< - AdminNotificationSettings, - "paused" ->; - export interface AdminNotificationTypePolicy { allowEmail: boolean; allowInApp: boolean; @@ -59,7 +50,7 @@ export interface AdminNotificationsOverview { queuedEmails: number; unreadItems: number; }; - email: { adapterConfigured: boolean; enabled: boolean }; + email: { adapterConfigured: boolean }; health: { cronActive: boolean; cronStale: boolean; 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 index f07e9175d..0f09418f2 100644 --- 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 @@ -1,50 +1,35 @@ import { cn } from "cn"; import { - BrushCleaningIcon, + CheckCheckIcon, ChevronRightIcon, type LucideIcon, MailCheckIcon, + MailXIcon, + PauseIcon, + PlayIcon, + Trash2Icon, } from "lucide-react"; import React from "react"; import { toast } from "sonner"; -import { useFormatter, useTranslations } from "use-intl"; -import { z } from "zod"; +import { useTranslations } from "use-intl"; import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; -import { AutoForm } from "@/components/form/auto-form"; -import { AutoFormNumber } from "@/components/form/fields/number"; -import { AutoFormSelect } from "@/components/form/fields/select"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; import type { NotificationsAdminActions } from "./notifications-mutations"; -import type { - AdminNotificationEditableSettings, - AdminNotificationsOverview, -} from "./notifications-query"; +import type { AdminNotificationsOverview } from "./notifications-query"; import { DeleteAllDialog } from "./delete-all-dialog"; export interface NotificationsSettingsSheetBodyProps { actions: NotificationsAdminActions; - canEdit: boolean; - canManage: boolean; counts: AdminNotificationsOverview["counts"]; emailConfigured: boolean; settings: AdminNotificationsOverview["settings"]; workers: AdminNotificationsOverview["workers"]; } -type NumberKey = "emailCapPerHour"; - -const LIMITS = { - emailCapPerHour: [0, 500], -} as const satisfies Record<NumberKey, readonly [number, number]>; - -const HOURS = Array.from({ length: 24 }, (_, hour) => String(hour)); -const WEEKDAYS = ["0", "1", "2", "3", "4", "5", "6"]; - const Group = ({ children, title, @@ -105,236 +90,6 @@ const RowText = ({ </span> ); -const rowButtonClass = - "focus-visible:ring-ring/50 flex min-h-12 w-full items-center gap-3 px-4 py-3 text-start outline-none focus-visible:ring-[3px] focus-visible:ring-inset [@media(hover:hover)]:hover:bg-muted/50"; - -const EditableRow = ({ - canEdit, - children, - isOpen, - label, - onToggle, - value, -}: { - canEdit: boolean; - children: React.ReactNode; - isOpen: boolean; - label: string; - onToggle: () => void; - value: string; -}) => { - const t = useTranslations("admin.system.notifications.settings"); - const panelId = React.useId(); - - if (!canEdit) { - return ( - <li className="flex min-h-12 items-center gap-3 px-4 py-3"> - <RowText label={label} /> - <span className="text-muted-foreground shrink-0 text-sm tabular-nums"> - {value} - </span> - </li> - ); - } - - return ( - <li> - <button - aria-controls={panelId} - aria-expanded={isOpen} - aria-label={`${t("edit", { label })}: ${value}`} - className={rowButtonClass} - onClick={onToggle} - type="button" - > - <RowText label={label} /> - <span className="text-muted-foreground shrink-0 text-sm tabular-nums"> - {value} - </span> - <ChevronRightIcon - aria-hidden - className={cn( - "text-muted-foreground size-4 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none", - isOpen && "rotate-90", - )} - /> - </button> - {isOpen ? ( - <div className="bg-muted/30 border-t px-4 py-4" id={panelId}> - {children} - </div> - ) : null} - </li> - ); -}; - -const EditorFooter = ({ - isSaving, - onCancel, -}: { - isSaving: boolean; - onCancel: () => void; -}) => { - const t = useTranslations("admin.system.notifications.settings"); - - return ( - <div className="flex justify-end gap-2"> - <Button onClick={onCancel} type="button" variant="ghost"> - {t("cancel")} - </Button> - <Button isLoading={isSaving} type="submit"> - {t("save")} - </Button> - </div> - ); -}; - -const useSettingSaver = ({ - actions, - onSaved, -}: { - actions: NotificationsAdminActions; - onSaved: () => void; -}) => { - const t = useTranslations("admin.system.notifications.settings"); - const tError = useTranslations("core.global.errors"); - const [isSaving, setIsSaving] = React.useState(false); - - const save = async ( - patch: Partial<AdminNotificationEditableSettings>, - label: string, - value: string, - ) => { - setIsSaving(true); - const mutation = await actions.updateSettings(patch); - setIsSaving(false); - - if (mutation.error !== undefined) { - toast.error(tError("title"), { - description: tError("internal_server_error"), - }); - - return; - } - - onSaved(); - toast.success(t("success"), { - description: t("success_desc", { label, value }), - }); - }; - - return { isSaving, save }; -}; - -const NumberEditor = ({ - actions, - display, - field, - onDone, - saved, -}: { - actions: NotificationsAdminActions; - display: (value: number) => string; - field: NumberKey; - onDone: () => void; - saved: number; -}) => { - const t = useTranslations("admin.system.notifications.settings"); - const [min, max] = LIMITS[field]; - const { isSaving, save } = useSettingSaver({ actions, onSaved: onDone }); - const formSchema = z.object({ - [field]: z.number().int().min(min).max(max).default(saved), - }); - - return ( - <AutoForm - fields={[ - { - id: field, - component: props => ( - <AutoFormNumber - {...props} - autoFocus - className="w-full sm:w-48" - description={t(`${field}.desc`)} - label={t(`${field}.label`)} - max={max} - min={min} - step={1} - /> - ), - }, - ]} - formSchema={formSchema} - layout={rendered => ( - <div className="flex flex-col gap-4"> - {rendered[field]} - <EditorFooter isSaving={isSaving} onCancel={onDone} /> - </div> - )} - onSubmit={async values => { - const value = values[field]; - await save({ [field]: value }, t(`${field}.label`), display(value)); - }} - /> - ); -}; - -const SelectEditor = ({ - actions, - field, - labels, - onDone, - saved, -}: { - actions: NotificationsAdminActions; - field: "digestHour" | "digestWeekday"; - labels: { label: string; value: string }[]; - onDone: () => void; - saved: number; -}) => { - const t = useTranslations("admin.system.notifications.settings"); - const { isSaving, save } = useSettingSaver({ actions, onSaved: onDone }); - const values = labels.map(item => item.value) as [string, ...string[]]; - const formSchema = z.object({ - [field]: z.enum(values).default(String(saved)), - }); - - return ( - <AutoForm - fields={[ - { - id: field, - component: props => ( - <AutoFormSelect - {...props} - description={t(`${field}.desc`)} - label={t(`${field}.label`)} - labels={labels} - /> - ), - }, - ]} - formSchema={formSchema} - layout={rendered => ( - <div className="flex flex-col gap-4"> - {rendered[field]} - <EditorFooter isSaving={isSaving} onCancel={onDone} /> - </div> - )} - onSubmit={async submitted => { - const value = Number(submitted[field]); - await save( - { [field]: value }, - t(`${field}.label`), - labels.find(item => item.value === String(value))?.label ?? - String(value), - ); - }} - /> - ); -}; - const ActionRow = ({ description, Icon, @@ -375,19 +130,34 @@ const ActionRow = ({ 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; }) => ( <li> {confirm( - <button className={rowButtonClass} type="button"> + <button + className="focus-visible:ring-ring/50 [@media(hover:hover)]:hover:bg-muted/50 flex min-h-12 w-full items-center gap-3 px-4 py-3 text-start outline-none focus-visible:ring-[3px] focus-visible:ring-inset" + type="button" + > + <span + className={cn( + "flex size-8 shrink-0 items-center justify-center rounded-lg", + tone === "danger" + ? "bg-destructive/10 text-destructive" + : "bg-muted text-foreground", + )} + > + <Icon aria-hidden className="size-4" /> + </span> <RowText description={description} label={title} @@ -405,8 +175,6 @@ const DangerRow = ({ export const NotificationsSettingsSheetBody = ({ actions, - canEdit, - canManage, counts, emailConfigured, settings, @@ -416,38 +184,6 @@ export const NotificationsSettingsSheetBody = ({ const tTools = useTranslations("admin.system.notifications.tools"); const tDanger = useTranslations("admin.system.notifications.danger"); const tError = useTranslations("core.global.errors"); - const format = useFormatter(); - const [openKey, setOpenKey] = React.useState<null | string>(null); - const emailSwitchId = React.useId(); - const [emailEnabled, setEmailEnabled] = React.useState(settings.emailEnabled); - - const toggle = (key: string) => () => { - setOpenKey(current => (current === key ? null : key)); - }; - const close = () => { - setOpenKey(null); - }; - - const hourLabels = HOURS.map(hour => ({ - label: format.dateTime(new Date(Date.UTC(2024, 0, 1, Number(hour))), { - hour: "2-digit", - minute: "2-digit", - timeZone: "UTC", - }), - value: hour, - })); - const weekdayLabels = WEEKDAYS.map(day => ({ - label: format.dateTime(new Date(Date.UTC(2024, 0, 7 + Number(day))), { - timeZone: "UTC", - weekday: "long", - }), - value: day, - })); - - const display = { - emailCapPerHour: (value: number) => - value === 0 ? t("emailCapPerHour.none") : format.number(value), - } satisfies Record<NumberKey, (value: number) => string>; const failed = () => { toast.error(tError("title"), { @@ -455,278 +191,174 @@ export const NotificationsSettingsSheetBody = ({ }); }; - const numberRow = (field: NumberKey) => ( - <EditableRow - canEdit={canEdit} - isOpen={openKey === field} - key={field} - label={t(`${field}.label`)} - onToggle={toggle(field)} - value={display[field](settings[field])} - > - <NumberEditor - actions={actions} - display={display[field]} - field={field} - onDone={close} - saved={settings[field]} - /> - </EditableRow> - ); - return ( <div className="flex flex-col gap-6 px-4 py-5"> - <Group title={t("groups.email")}> - <li className="flex min-h-12 items-center justify-between gap-3 px-4 py-3"> - <label className="flex flex-col gap-0.5" htmlFor={emailSwitchId}> - <span className="text-sm font-medium"> - {t("emailEnabled.label")} - </span> - <span className="text-muted-foreground text-xs leading-relaxed text-pretty"> - {t("emailEnabled.desc")} - </span> - </label> - <Switch - checked={emailEnabled} - disabled={!canEdit} - id={emailSwitchId} - onCheckedChange={checked => { - setEmailEnabled(checked); - void actions - .updateSettings({ emailEnabled: checked }) - .then(mutation => { + {emailConfigured ? ( + <Group title={t("groups.tools")}> + <ActionRow + description={tTools("test_email.desc")} + Icon={MailCheckIcon} + label={tTools("test_email.label")} + onRun={async () => { + 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, + }), + }); + }} + /> + </Group> + ) : null} + + <Group title={t("groups.danger")} tone="danger"> + <li className="text-muted-foreground px-4 py-3 text-xs leading-relaxed"> + {tDanger("intro")} + </li> + {settings.paused ? ( + <DangerRow + confirm={trigger => ( + <ConfirmActionAlertDialog + description={tDanger("resume.confirm_desc")} + onSubmit={async ({ onClose }) => { + const mutation = await actions.resume(); if (mutation.error !== undefined) { - setEmailEnabled(!checked); failed(); return; } - toast.success(t("success"), { - description: t("success_desc", { - label: t("emailEnabled.label"), - value: checked ? t("on") : t("off"), + onClose(); + toast.success(tDanger("resume.success"), { + description: tDanger("resume.success_desc", { + count: mutation.data.requeuedEvents, }), }); - }); - }} - /> - </li> - {numberRow("emailCapPerHour")} - <EditableRow - canEdit={canEdit} - isOpen={openKey === "digestHour"} - label={t("digestHour.label")} - onToggle={toggle("digestHour")} - value={hourLabels[settings.digestHour]?.label ?? ""} - > - <SelectEditor - actions={actions} - field="digestHour" - labels={hourLabels} - onDone={close} - saved={settings.digestHour} - /> - </EditableRow> - <EditableRow - canEdit={canEdit} - isOpen={openKey === "digestWeekday"} - label={t("digestWeekday.label")} - onToggle={toggle("digestWeekday")} - value={weekdayLabels[settings.digestWeekday]?.label ?? ""} - > - <SelectEditor - actions={actions} - field="digestWeekday" - labels={weekdayLabels} - onDone={close} - saved={settings.digestWeekday} + }} + submitVariant="default" + textSubmit={tDanger("resume.submit")} + title={`${tDanger("resume.title")}?`} + > + {trigger} + </ConfirmActionAlertDialog> + )} + description={tDanger("resume.desc")} + Icon={PlayIcon} + title={tDanger("resume.title")} + tone="safe" + trailing={ + <Badge variant="warning">{tDanger("paused_badge")}</Badge> + } /> - </EditableRow> - </Group> - - {canManage ? ( - <> - <Group title={t("groups.tools")}> - {emailConfigured ? ( - <ActionRow - description={tTools("test_email.desc")} - Icon={MailCheckIcon} - label={tTools("test_email.label")} - onRun={async () => { - const mutation = await actions.sendTestEmail(); + ) : ( + <DangerRow + confirm={trigger => ( + <ConfirmActionAlertDialog + description={tDanger("pause.confirm_desc")} + onSubmit={async ({ onClose }) => { + const mutation = await actions.pause(); if (mutation.error !== undefined) { - if (mutation.status === 400) { - toast.error(tError("title"), { - description: tTools("test_email.not_configured"), - }); - } else failed(); + failed(); return; } - toast.success(tTools("test_email.success"), { - description: tTools("test_email.success_desc", { - id: mutation.data.deliveryId, - }), + onClose(); + toast.success(tDanger("pause.success"), { + description: tDanger("pause.success_desc"), }); }} - /> - ) : null} - <ActionRow - description={tTools("cleanup.desc", { - count: workers.retentionDays, + textSubmit={tDanger("pause.submit")} + title={`${tDanger("pause.title")}?`} + > + {trigger} + </ConfirmActionAlertDialog> + )} + description={tDanger("pause.desc")} + Icon={PauseIcon} + title={tDanger("pause.title")} + /> + )} + <DangerRow + confirm={trigger => ( + <ConfirmActionAlertDialog + description={tDanger("cancel_emails.confirm_desc", { + count: counts.queuedEmails, })} - Icon={BrushCleaningIcon} - label={tTools("cleanup.label")} - onRun={async () => { - const mutation = await actions.cleanup(); + onSubmit={async ({ onClose }) => { + const mutation = await actions.cancelQueuedEmails(); if (mutation.error !== undefined) { failed(); return; } - toast.success(tTools("cleanup.success"), { - description: tTools("cleanup.success_desc"), + onClose(); + toast.success(tDanger("cancel_emails.success"), { + description: tDanger("cancel_emails.success_desc", { + count: mutation.data.cancelled, + }), }); }} - /> - </Group> - - <Group title={t("groups.danger")} tone="danger"> - <li className="text-muted-foreground px-4 py-3 text-xs leading-relaxed"> - {tDanger("intro")} - </li> - {settings.paused ? ( - <DangerRow - confirm={trigger => ( - <ConfirmActionAlertDialog - description={tDanger("resume.confirm_desc")} - onSubmit={async ({ onClose }) => { - const mutation = await actions.resume(); - if (mutation.error !== undefined) { - failed(); + textSubmit={tDanger("cancel_emails.submit")} + title={`${tDanger("cancel_emails.title")}?`} + > + {trigger} + </ConfirmActionAlertDialog> + )} + description={tDanger("cancel_emails.desc")} + Icon={MailXIcon} + title={tDanger("cancel_emails.title")} + /> + <DangerRow + confirm={trigger => ( + <ConfirmActionAlertDialog + description={tDanger("read_all.confirm_desc", { + count: counts.unreadItems, + })} + onSubmit={async ({ onClose }) => { + const mutation = await actions.markEverythingRead(); + 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} - </ConfirmActionAlertDialog> - )} - description={tDanger("resume.desc")} - title={tDanger("resume.title")} - tone="safe" - trailing={ - <Badge variant="warning">{tDanger("paused_badge")}</Badge> + return; } - /> - ) : ( - <DangerRow - confirm={trigger => ( - <ConfirmActionAlertDialog - description={tDanger("pause.confirm_desc")} - onSubmit={async ({ onClose }) => { - 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} - </ConfirmActionAlertDialog> - )} - description={tDanger("pause.desc")} - title={tDanger("pause.title")} - /> - )} - <DangerRow - confirm={trigger => ( - <ConfirmActionAlertDialog - description={tDanger("cancel_emails.confirm_desc", { - count: counts.queuedEmails, - })} - onSubmit={async ({ onClose }) => { - 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} - </ConfirmActionAlertDialog> - )} - description={tDanger("cancel_emails.desc")} - title={tDanger("cancel_emails.title")} - /> - <DangerRow - confirm={trigger => ( - <ConfirmActionAlertDialog - description={tDanger("read_all.confirm_desc", { - count: counts.unreadItems, - })} - onSubmit={async ({ onClose }) => { - 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} - </ConfirmActionAlertDialog> - )} - description={tDanger("read_all.desc")} - title={tDanger("read_all.title")} - /> - <DangerRow - confirm={trigger => ( - <DeleteAllDialog - count={counts.inboxItems} - onDelete={actions.deleteAll} - trigger={trigger} - /> - )} - description={tDanger("delete_all.desc")} - title={tDanger("delete_all.title")} + 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} + </ConfirmActionAlertDialog> + )} + description={tDanger("read_all.desc")} + Icon={CheckCheckIcon} + title={tDanger("read_all.title")} + /> + <DangerRow + confirm={trigger => ( + <DeleteAllDialog + count={counts.inboxItems} + onDelete={actions.deleteAll} + trigger={trigger} /> - </Group> - </> - ) : null} + )} + description={tDanger("delete_all.desc")} + Icon={Trash2Icon} + title={tDanger("delete_all.title")} + /> + </Group> <p className="text-muted-foreground px-1 text-xs leading-relaxed text-pretty"> {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 index 4430b3100..038eafe9d 100644 --- 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 @@ -52,9 +52,7 @@ export const NotificationsSettingsSheet = ( <SheetContent className="w-full gap-0 data-[side=right]:w-full data-[side=right]:sm:max-w-md"> <SheetHeader className="border-b"> <SheetTitle>{t("title")}</SheetTitle> - <SheetDescription> - {props.canEdit ? t("desc") : t("read_only")} - </SheetDescription> + <SheetDescription>{t("desc")}</SheetDescription> </SheetHeader> <div className="bg-muted/30 flex-1 overflow-y-auto"> <React.Suspense fallback={<SheetBodySkeleton />}> 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} /> + <TimeZoneGroup onUpdate={onTimeZoneUpdate} timeZone={user.timeZone} /> + <SettingsGroup title={t("accountTitle")}> <li className={SETTINGS_ROW}> <span className={SETTINGS_ROW_LABEL}>{t("nickname")}</span> 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..b20237374 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/overview/time-zone-editor.tsx @@ -0,0 +1,119 @@ +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 } 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: timeZone.replace(/_/g, " ") }) + : t("timeZoneAuto"), + }); + onClose(); + }; + + const formSchema = z.object({ + value: z + .enum(zones as [string, ...string[]]) + .default(value ?? device ?? "UTC"), + }); + + const onSubmit: AutoFormOnSubmit<typeof formSchema> = async values => { + await save(values.value); + }; + + return ( + <AutoForm + className="flex flex-col gap-3 space-y-0 px-4 py-4" + fields={[ + { + component: props => ( + <AutoFormCombobox + {...props} + description={t("timeZoneDesc")} + label={t("timeZone")} + /> + ), + id: "value", + }, + ]} + formSchema={formSchema} + layout={rendered => ( + <> + {rendered.value} + <div className="flex flex-wrap items-center justify-between gap-2"> + {device && device !== value ? ( + <Button + className="h-auto px-0" + isLoading={isUsingDevice} + onClick={() => { + setIsUsingDevice(true); + void save(device).finally(() => { + setIsUsingDevice(false); + }); + }} + type="button" + variant="link" + > + {t("timeZoneUseDevice", { + timeZone: device.replace(/_/g, " "), + })} + </Button> + ) : ( + <span /> + )} + <div className="flex gap-2"> + <Button onClick={onClose} type="button" variant="ghost"> + {tGlobal("cancel")} + </Button> + <AutoFormSubmitButton>{t("save")}</AutoFormSubmitButton> + </div> + </div> + </> + )} + 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..052f72e22 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/overview/time-zone-row.tsx @@ -0,0 +1,81 @@ +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"; + +const TimeZoneEditor = React.lazy(async () => + import("./time-zone-editor").then(module => ({ + default: module.TimeZoneEditor, + })), +); + +const TimeZoneEditorSkeleton = () => ( + <div aria-hidden className="flex flex-col gap-3 px-4 py-4"> + <Skeleton className="h-14 w-full rounded-md" /> + <div className="flex justify-end gap-2"> + <Skeleton className="h-9 w-20 rounded-md" /> + <Skeleton className="h-9 w-28 rounded-md" /> + </div> + </div> +); + +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<HTMLButtonElement>(null); + + const close = () => { + setIsEditing(false); + requestAnimationFrame(() => rowRef.current?.focus()); + }; + + return ( + <SettingsGroup footer={t("regionDesc")} title={t("regionTitle")}> + <li> + {isEditing ? ( + <React.Suspense fallback={<TimeZoneEditorSkeleton />}> + <TimeZoneEditor + onClose={close} + onUpdate={onUpdate} + value={timeZone} + /> + </React.Suspense> + ) : ( + <button + className={SETTINGS_INTERACTIVE_ROW} + onClick={() => { + setIsEditing(true); + }} + ref={rowRef} + type="button" + > + <span className={SETTINGS_ROW_LABEL}>{t("timeZone")}</span> + <span className="text-muted-foreground min-w-0 flex-1 truncate text-end text-sm"> + {timeZone ? timeZone.replace(/_/g, " ") : t("timeZoneAuto")} + </span> + <span className="sr-only">{t("edit")}</span> + <ChevronRightIcon + aria-hidden="true" + className="text-muted-foreground size-4 shrink-0 rtl:rotate-180" + /> + </button> + )} + </li> + </SettingsGroup> + ); +}; 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..75c44a58e --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/overview/time-zone-update.ts @@ -0,0 +1,48 @@ +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<UpdateTimeZoneResult>; + +export const updateTimeZoneInBrowser: UpdateTimeZone = async timeZone => { + try { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: { timeZone } }, + method: "put", + module: "users", + options: { credentials: "include" }, + path: "/me/time-zone", + }); + + return response.ok + ? { data: true } + : { error: { status: response.status } }; + } catch { + return { error: { status: 500 } }; + } +}; + +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/notifications/notifications-actions.ts b/packages/vitnode/src/views/notifications/notifications-actions.ts index 0b74c2661..593f29d7f 100644 --- a/packages/vitnode/src/views/notifications/notifications-actions.ts +++ b/packages/vitnode/src/views/notifications/notifications-actions.ts @@ -1,7 +1,4 @@ -import type { - NotificationState, - NotificationSubject, -} from "@/lib/notifications/types"; +import type { NotificationState } from "@/lib/notifications/types"; import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; @@ -84,12 +81,13 @@ export const markAllNotificationsReadInBrowser = async ({ }; export interface UpdateNotificationPreferencesBody { - digestHour?: number; - digestWeekday?: number; - timeZone?: null | string; types?: Record< string, - { email?: "daily" | "immediate" | "none" | "weekly"; inApp?: boolean } + { + email?: "daily" | "immediate" | "none" | "weekly"; + inApp?: boolean; + push?: boolean; + } >; } @@ -106,30 +104,3 @@ export const updateNotificationPreferencesInBrowser = async ( }); if (!response.ok) failed(response); }; - -export type NotificationSubscriptionState = "following" | "muted" | "none"; - -export const setNotificationSubscriptionInBrowser = async ({ - state, - subject, -}: { - state: NotificationSubscriptionState; - subject: NotificationSubject; -}): Promise<NotificationSubscriptionState> => { - const response = await fetcherClient({ - plugin: CONFIG_PLUGIN.pluginId, - args: { - body: { - state, - subjectId: String(subject.id), - subjectType: subject.type, - }, - }, - method: "put", - module: "notifications", - options: { credentials: "include" }, - path: "/subscriptions", - }); - - return response.ok ? (await response.json()).state : failed(response); -}; diff --git a/packages/vitnode/src/views/notifications/notifications-query.ts b/packages/vitnode/src/views/notifications/notifications-query.ts index 457d97f6e..819a2da90 100644 --- a/packages/vitnode/src/views/notifications/notifications-query.ts +++ b/packages/vitnode/src/views/notifications/notifications-query.ts @@ -1,7 +1,5 @@ import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; -import type { NotificationSubject } from "@/lib/notifications/types"; - import { CONFIG_PLUGIN } from "@/config"; import { OPERATIONAL_STALE_TIME, @@ -163,6 +161,7 @@ export const fetchNotificationState = async () => { export interface NotificationPreferenceTypeView { category: string; categoryLabel: string; + defaultEmail: "daily" | "immediate" | "none" | "weekly"; description: null | string; emailModes: ("daily" | "immediate" | "none" | "weekly")[]; id: string; @@ -171,13 +170,15 @@ export interface NotificationPreferenceTypeView { locked: boolean; mandatory: boolean; pluginId: string; - value: { email: "daily" | "immediate" | "none" | "weekly"; inApp: boolean }; + pushAvailable: boolean; + value: { + email: "daily" | "immediate" | "none" | "weekly"; + inApp: boolean; + push: boolean; + }; } export interface NotificationPreferencesView { - digestHour: number; - digestWeekday: number; - timeZone: null | string; types: NotificationPreferenceTypeView[]; } @@ -205,68 +206,3 @@ export const notificationPreferencesQueryOptions = ({ retry: false, staleTime: RECORD_STALE_TIME, }); - -export interface NotificationSubscriptionView { - createdAt: Date | string; - label: null | string; - state: "following" | "muted"; - subjectId: string; - subjectType: string; -} - -export const notificationSubscriptionsQueryKey = (userId: number) => - [...NOTIFICATIONS_IDENTITY_ROOT, userId, "subscriptions"] as const; - -export const notificationSubscriptionsQueryOptions = ({ - userId, -}: { - userId: number; -}) => - queryOptions({ - queryFn: async (): Promise<NotificationSubscriptionView[]> => { - const response = await fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - args: { query: {} }, - method: "get", - module: "notifications", - path: "/subscriptions", - }); - if (!response.ok) throw new NotificationsRequestError(response.status); - - return (await response.json()).items; - }, - queryKey: notificationSubscriptionsQueryKey(userId), - retry: false, - staleTime: RECORD_STALE_TIME, - }); - -export const notificationSubscriptionQueryOptions = ({ - subject, - userId, -}: { - subject: NotificationSubject; - userId: number; -}) => - queryOptions({ - queryFn: async () => { - const response = await fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - args: { - params: { subjectId: String(subject.id), subjectType: subject.type }, - }, - method: "get", - module: "notifications", - path: "/subscriptions/{subjectType}/{subjectId}", - }); - if (!response.ok) throw new NotificationsRequestError(response.status); - - return (await response.json()).state; - }, - queryKey: [ - ...notificationSubscriptionsQueryKey(userId), - subject.type, - String(subject.id), - ] as const, - 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<PushPermission> => { + 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 ( + <li className="flex items-center gap-3 px-4 py-4"> + <Icon + aria-hidden + className="text-muted-foreground size-5 shrink-0 self-start" + strokeWidth={1.75} + /> + <Text className="flex min-w-0 flex-1 flex-col gap-0.5" htmlFor={htmlFor}> + <span className="text-sm font-medium">{title}</span> + <span className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {description} + </span> + {children} + </Text> + {trailing ? <div className="shrink-0">{trailing}</div> : null} + </li> + ); +}; + +const PushRow = () => { + const t = useTranslations("core.auth.settings.notifications.channels.push"); + const permission = React.useSyncExternalStore<null | PushPermission>( + subscribeToPermission, + readPushPermission, + () => null, + ); + const [isAsking, setIsAsking] = React.useState(false); + const blocked = permission === "denied" || permission === "unsupported"; + + return ( + <ChannelRow + description={blocked ? t(permission) : t("desc")} + Icon={SmartphoneIcon} + title={t("title")} + trailing={ + permission === "granted" ? ( + <span className="text-success flex items-center gap-1.5 text-sm font-medium"> + <CheckIcon aria-hidden className="size-4" /> + {t("enabled")} + </span> + ) : permission === "default" ? ( + <Button + aria-label={t("enable")} + isLoading={isAsking} + onClick={() => { + setIsAsking(true); + void requestPushPermission().then(next => { + setIsAsking(false); + if (next === "granted") { + toast.success(t("enabled_toast"), { + description: t("enabled_toast_desc"), + }); + } + }); + }} + size="sm" + variant="outline" + > + <BellIcon aria-hidden /> + {t("enable_short")} + </Button> + ) : null + } + > + <span className="text-muted-foreground/80 text-xs leading-relaxed text-pretty"> + {t("soon")} + </span> + </ChannelRow> + ); +}; + +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<HTMLButtonElement>(null); + const [confirmOpen, setConfirmOpen] = React.useState(false); + const address = () => ( + <span className="text-foreground font-medium break-all">{email}</span> + ); + + return ( + <SettingsGroup title={t("channels.title")}> + <PushRow /> + <ChannelRow + description={ + emailState === "unavailable" + ? t("channels.email.unavailable") + : emailState === "on" + ? t.rich("channels.email.desc", { email: address }) + : t.rich("channels.email.desc_off", { email: address }) + } + htmlFor={emailState === "unavailable" ? undefined : emailSwitchId} + Icon={MailIcon} + title={t("channels.email.title")} + trailing={ + emailState === "unavailable" ? null : ( + <Switch + checked={emailState === "on"} + id={emailSwitchId} + onCheckedChange={on => { + if (on) onEmailChange(true); + else setConfirmOpen(true); + }} + ref={emailSwitchRef} + /> + ) + } + /> + + <AlertDialog onOpenChange={setConfirmOpen} open={confirmOpen}> + <AlertDialogContent finalFocus={emailSwitchRef}> + <AlertDialogHeader> + <AlertDialogTitle> + {t("channels.email.stop_title")} + </AlertDialogTitle> + <AlertDialogDescription className="leading-relaxed text-pretty"> + {t("channels.email.stop_desc")} + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <Button + onClick={() => { + setConfirmOpen(false); + }} + variant="outline" + > + {t("channels.email.stop_cancel")} + </Button> + <Button + onClick={() => { + setConfirmOpen(false); + onEmailChange(false); + }} + variant="destructive" + > + {t("channels.email.stop_submit")} + </Button> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </SettingsGroup> + ); +}; 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> = {}, +): 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<NotificationPreferenceTypeView> = {}, +) => + 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..efe69f751 --- /dev/null +++ b/packages/vitnode/src/views/notifications/settings/email-channel.ts @@ -0,0 +1,53 @@ +import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; + +type EmailMode = NotificationPreferenceTypeView["value"]["email"]; + +export type EmailChannelState = "off" | "on" | "unavailable"; + +export 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): EmailMode => { + if (type.defaultEmail !== "none") return type.defaultEmail; + + return type.emailModes.includes("daily") ? "daily" : "immediate"; +}; + +export const emailOnPatch = ( + types: NotificationPreferenceTypeView[], + before: null | Record<string, EmailMode>, +): Record<string, { email: EmailMode }> => + 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..3a9ebfe11 --- /dev/null +++ b/packages/vitnode/src/views/notifications/settings/type-item.tsx @@ -0,0 +1,299 @@ +import { cn } from "cn"; +import { + BellIcon, + ChevronDownIcon, + LockIcon, + type LucideIcon, + MailIcon, + SmartphoneIcon, +} from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; + +import { + NativeSelect, + NativeSelectOption, +} from "@/components/ui/native-select"; +import { Switch } from "@/components/ui/switch"; +import { + REVEAL_EXIT_TRANSITION, + REVEAL_HIDDEN, + REVEAL_SHOWN, + REVEAL_TRANSITION, +} from "@/lib/motion"; + +type EmailMode = NotificationPreferenceTypeView["value"]["email"]; + +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 ( + <span + className={cn( + "flex size-7 items-center justify-center rounded-md", + !available + ? "text-muted-foreground/30" + : on + ? "bg-primary/10 text-primary" + : "text-muted-foreground/60", + )} + > + <Icon aria-hidden className="size-4" /> + <span className="sr-only"> + {label}: {!available ? t("unavailable") : on ? t("on") : t("off")} + </span> + </span> + ); +}; + +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 ( + <div className="flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between"> + <label + className="flex min-w-0 flex-1 cursor-pointer items-start gap-3" + htmlFor={id} + > + <Icon + aria-hidden + className="text-muted-foreground mt-0.5 size-4 shrink-0" + /> + <span className="flex flex-col gap-0.5"> + <span className="text-sm font-medium">{label}</span> + <span className="text-muted-foreground text-xs leading-relaxed"> + {description} + </span> + </span> + </label> + <div className="flex items-center gap-3 ps-7 sm:ps-0"> + {children} + <Switch + checked={value} + disabled={disabled} + id={id} + onCheckedChange={onChange} + /> + </div> + </div> + ); +}; + +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 frequencyId = React.useId(); + const shouldReduceMotion = useReducedMotion(); + const emailOn = type.value.email !== "none"; + const emailShown = type.emailModes.length > 0 || type.locked; + const lastFrequencyRef = React.useRef<EmailMode>( + emailOn ? type.value.email : "daily", + ); + const frequencies = type.emailModes.filter(mode => mode !== "none"); + + return ( + <li className="border-b last:border-b-0"> + <div className="has-focus-visible:ring-ring/50 [@media(hover:hover)]:hover:bg-muted/40 relative flex items-center gap-3 px-4 py-3 has-focus-visible:ring-[3px] has-focus-visible:ring-inset"> + <div className="flex min-w-0 flex-1 flex-col gap-0.5"> + <h4 className="flex items-center gap-1.5 text-sm font-medium text-pretty"> + {type.locked ? ( + <LockIcon + aria-label={t("locked")} + className="text-muted-foreground size-3.5 shrink-0" + /> + ) : null} + <button + aria-controls={panelId} + aria-expanded={isOpen} + className="text-start outline-none after:absolute after:inset-0" + onClick={onToggle} + type="button" + > + {type.label} + </button> + </h4> + {type.description ? ( + <span className="text-muted-foreground truncate text-xs"> + {type.description} + </span> + ) : null} + </div> + <span className="flex shrink-0 items-center gap-0.5"> + <ChannelChip + available={type.inAppAvailable} + Icon={BellIcon} + label={t("types.summary.list")} + on={type.value.inApp} + /> + <ChannelChip + available={type.pushAvailable} + Icon={SmartphoneIcon} + label={t("types.summary.push")} + on={type.value.push} + /> + <ChannelChip + available={emailShown} + Icon={MailIcon} + label={t("types.summary.email")} + on={emailOn} + /> + </span> + <ChevronDownIcon + aria-hidden + className={cn( + "text-muted-foreground size-4 shrink-0 transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none", + isOpen && "rotate-180", + )} + /> + </div> + + <AnimatePresence initial={false}> + {isOpen ? ( + <motion.div + animate={REVEAL_SHOWN} + className="overflow-y-clip" + exit={ + shouldReduceMotion + ? undefined + : { ...REVEAL_HIDDEN, transition: REVEAL_EXIT_TRANSITION } + } + id={panelId} + initial={shouldReduceMotion ? false : REVEAL_HIDDEN} + key="panel" + transition={REVEAL_TRANSITION} + > + <div className="bg-muted/30 flex flex-col divide-y border-t px-4 py-1"> + {type.locked ? ( + <p className="text-muted-foreground flex items-center gap-1.5 py-3 text-sm"> + <LockIcon aria-hidden className="size-3.5" /> + {type.mandatory ? t("types.mandatory") : t("types.locked")} + </p> + ) : null} + {type.inAppAvailable ? ( + <ChannelSwitch + description={t("types.list_desc")} + disabled={type.locked} + Icon={BellIcon} + label={t("types.summary.list")} + onChange={inApp => { + onChange({ inApp }); + }} + value={type.value.inApp} + /> + ) : null} + {type.pushAvailable ? ( + <ChannelSwitch + description={t("types.push_desc")} + disabled={type.locked} + Icon={SmartphoneIcon} + label={t("types.summary.push")} + onChange={push => { + onChange({ push }); + }} + value={type.value.push} + /> + ) : null} + {emailShown ? ( + <ChannelSwitch + description={ + type.value.email === "daily" + ? t("types.email_daily") + : type.value.email === "weekly" + ? t("types.email_weekly") + : type.value.email === "immediate" + ? t("types.email_immediate") + : t("types.email_off") + } + disabled={type.locked} + Icon={MailIcon} + label={t("types.summary.email")} + onChange={on => { + if (!on && emailOn) + lastFrequencyRef.current = type.value.email; + onChange({ email: on ? lastFrequencyRef.current : "none" }); + }} + value={emailOn} + > + {emailOn && !type.locked && frequencies.length > 1 ? ( + <> + <label className="sr-only" htmlFor={frequencyId}> + {t("types.frequency_label", { type: type.label })} + </label> + <NativeSelect + className="w-44 text-base sm:text-sm" + id={frequencyId} + onChange={event => { + const next = frequencies.find( + mode => mode === event.target.value, + ); + if (!next) return; + lastFrequencyRef.current = next; + onChange({ email: next }); + }} + size="sm" + value={type.value.email} + > + {frequencies.map(mode => ( + <NativeSelectOption key={mode} value={mode}> + {t(`email_modes.${mode}`)} + </NativeSelectOption> + ))} + </NativeSelect> + </> + ) : type.locked && emailOn ? ( + <span className="text-muted-foreground text-sm"> + {t(`email_modes.${type.value.email}`)} + </span> + ) : null} + </ChannelSwitch> + ) : null} + </div> + </motion.div> + ) : null} + </AnimatePresence> + </li> + ); +}; diff --git a/plugins/blog/src/api/lib/notifications.ts b/plugins/blog/src/api/lib/notifications.ts deleted file mode 100644 index 27fee6829..000000000 --- a/plugins/blog/src/api/lib/notifications.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { EnvVitNode } from "@vitnode/core/api/middlewares/global.middleware"; -import type { Context } from "hono"; - -import { buildEventListener } from "@vitnode/core/api/lib/events"; -import { - buildNotificationSubject, - buildNotificationType, -} from "@vitnode/core/api/lib/notifications/registry"; -import { contentEventName } from "@vitnode/core/content"; -import { core_languages } from "@vitnode/core/database/languages"; -import { and, asc, eq, inArray } from "drizzle-orm"; -import { z } from "zod"; - -import { blogPostContentType } from "@/content/post"; -import { - blog_categories, - blog_categories_translations, -} from "@/database/categories"; -import { - blog_posts_category_id, - blog_posts_translations, -} from "@/database/posts"; - -/** A blog category readers can follow to hear about new articles in it. */ -export const BLOG_CATEGORY_SUBJECT = "blog.category"; - -// Both content types are localized, so their translation tables always exist; -// the model types them as optional because a type without localization has none. -const requireTable = <T>(table: null | T, name: string): T => { - if (!table) throw new Error(`[Blog] The ${name} table is missing.`); - - return table; -}; -const postTranslations = requireTable( - blog_posts_translations, - "blog_posts_translations", -); -const categoryTranslations = requireTable( - blog_categories_translations, - "blog_categories_translations", -); - -const isPublished = async ( - c: Context<EnvVitNode>, - postId: number, -): Promise<boolean> => { - const [row] = await c - .get("db") - .select({ itemId: postTranslations.itemId }) - .from(postTranslations) - .where( - and( - eq(postTranslations.itemId, postId), - eq(postTranslations.status, "published"), - ), - ) - .limit(1); - - return !!row; -}; - -/** - * "New article in a category you follow". The audience is whoever follows any - * of the article's categories - core streams that list itself, so a category - * with ten thousand followers costs this listener one query. - */ -export const postPublishedNotification = buildNotificationType({ - id: "blog.post_published", - version: 1, - schema: z.object({ - postId: z.number().int().positive(), - slug: z.string().min(1).max(255), - title: z.string().min(1).max(255), - }), - category: "content", - label: "@vitnode/blog.notifications.post_published.label", - description: "@vitnode/blog.notifications.post_published.description", - subjectType: "blog.post", - defaults: { email: "daily", inApp: true }, - email: true, - // Every recipient shares one answer: an article is public once published. - // Unpublished or deleted since? Then nobody sees it - in the inbox it turns - // into the "no longer available" placeholder, and no email goes out. - access: async ({ c, data, userIds }) => - (await isPublished(c, data.postId)) ? userIds : [], - present: ({ data, t }) => ({ - body: t("@vitnode/blog.notifications.post_published.body"), - target: `/blog/${encodeURIComponent(data.slug)}`, - title: t("@vitnode/blog.notifications.post_published.title", { - title: data.title, - }), - }), -}); - -export const blogCategorySubject = buildNotificationSubject({ - type: BLOG_CATEGORY_SUBJECT, - canFollow: async ({ c, id }) => { - const categoryId = Number(id); - if (!Number.isSafeInteger(categoryId)) return false; - - const [row] = await c - .get("db") - .select({ id: blog_categories.id }) - .from(blog_categories) - .where(eq(blog_categories.id, categoryId)) - .limit(1); - - return !!row; - }, - resolveLabels: async ({ c, ids, locale }) => { - const categoryIds = ids.map(Number).filter(Number.isSafeInteger); - if (categoryIds.length === 0) return {}; - - const rows = await c - .get("db") - .select({ - code: core_languages.code, - id: categoryTranslations.itemId, - name: categoryTranslations.name, - }) - .from(categoryTranslations) - .innerJoin( - core_languages, - eq(core_languages.id, categoryTranslations.languageId), - ) - .where(inArray(categoryTranslations.itemId, categoryIds)); - - const labels: Record<string, string> = {}; - for (const row of rows) { - // The reader's language wins; any other translation is a fallback. - if (row.code === locale || !(String(row.id) in labels)) { - labels[String(row.id)] = row.name; - } - } - - return labels; - }, -}); - -/** The article's title and slug in its default language, falling back to any. */ -const loadPostSummary = async (c: Context<EnvVitNode>, postId: number) => { - const rows = await c - .get("db") - .select({ - code: core_languages.code, - slug: postTranslations.friendlyUrl, - title: postTranslations.title, - }) - .from(postTranslations) - .innerJoin( - core_languages, - eq(core_languages.id, postTranslations.languageId), - ) - .where(eq(postTranslations.itemId, postId)); - - return ( - rows.find( - row => row.code === blogPostContentType.localization.defaultLocale, - ) ?? rows[0] - ); -}; - -export const notifyCategoryFollowersListener = buildEventListener({ - event: contentEventName(blogPostContentType.id, "published"), - name: "notify-category-followers", - description: - "Tells followers of an article's categories that it was published", - handler: async (c, payload) => { - const postId = payload.contentId; - const [summary, categories] = await Promise.all([ - loadPostSummary(c, postId), - c - .get("db") - .select({ categoryId: blog_posts_category_id.relatedItemId }) - .from(blog_posts_category_id) - .where(eq(blog_posts_category_id.itemId, postId)) - .orderBy(asc(blog_posts_category_id.position)), - ]); - if (!summary || categories.length === 0) return; - - await c.get("notifications").publish({ - type: postPublishedNotification, - // Whoever pressed publish - or nobody, when a schedule fired it - is - // left out of their own announcement. - actorId: payload.scheduledBy ?? c.get("admin")?.user.id ?? null, - followersOf: categories.map(row => ({ - id: row.categoryId, - type: BLOG_CATEGORY_SUBJECT, - })), - subject: { id: postId, type: "blog.post" }, - data: { postId, slug: summary.slug, title: summary.title }, - // `publishedAt` is the first publication and never changes, so this - // announces an article once - not again after an unpublish and republish, - // and not twice when a scheduled publish is retried. - idempotencyKey: `post-published:${postId}`, - }); - }, -}); diff --git a/plugins/blog/src/api/modules/admin/admin.module.ts b/plugins/blog/src/api/modules/admin/admin.module.ts index 51bbd137c..79b10572c 100644 --- a/plugins/blog/src/api/modules/admin/admin.module.ts +++ b/plugins/blog/src/api/modules/admin/admin.module.ts @@ -2,7 +2,6 @@ import { buildModule } from "@vitnode/core/api/lib/module"; import { buildContentAdminModule } from "@vitnode/core/content/server"; import { blogLegacyEventListeners } from "@/api/lib/events"; -import { notifyCategoryFollowersListener } from "@/api/lib/notifications"; import { CONFIG_PLUGIN } from "@/const"; import { categoryContent } from "@/database/categories"; import { postContent } from "@/database/posts"; @@ -19,5 +18,5 @@ export const adminModule = buildModule({ ], // Event listeners are only collected from top-level modules, so the // compatibility adapters are registered here rather than on a nested one. - events: [...blogLegacyEventListeners, notifyCategoryFollowersListener], + events: blogLegacyEventListeners, }); diff --git a/plugins/blog/src/config.api.ts b/plugins/blog/src/config.api.ts index 7a4503434..5e5404c0e 100644 --- a/plugins/blog/src/config.api.ts +++ b/plugins/blog/src/config.api.ts @@ -3,22 +3,14 @@ import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin"; import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; import { buildContentPublicModule } from "@vitnode/core/content/server"; -import { - blogCategorySubject, - postPublishedNotification, -} from "@/api/lib/notifications"; import { adminModule } from "@/api/modules/admin/admin.module"; import { CONFIG_PLUGIN } from "@/const"; import { categoryContent } from "@/database/categories"; import { postContent } from "@/database/posts"; -import apiMessages from "@/locales/api"; export const blogApiPlugin = () => buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, - messages: apiMessages, - notificationTypes: [postPublishedNotification], - notificationSubjects: [blogCategorySubject], modules: [ adminModule, buildContentPublicModule({ diff --git a/plugins/blog/src/locales/api/en.json b/plugins/blog/src/locales/api/en.json deleted file mode 100644 index 06ce1b6e7..000000000 --- a/plugins/blog/src/locales/api/en.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "@vitnode/blog": { - "notifications": { - "post_published": { - "label": "New articles in categories you follow", - "description": "When an article is published in a blog category you follow.", - "title": "New article: {title}", - "body": "Fresh off the press in a category you follow." - } - } - } -} diff --git a/plugins/blog/src/locales/api/index.ts b/plugins/blog/src/locales/api/index.ts deleted file mode 100644 index 346cb9825..000000000 --- a/plugins/blog/src/locales/api/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types"; - -/** Strings the API renders - notifications and their emails. */ -const messages: LocaleMessagesMap = { - en: async () => await import("./en.json", { with: { type: "json" } }), -}; - -export default messages; diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index fdd6c8a92..917475a3d 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -54,8 +54,7 @@ } }, "post": { - "published": "Published", - "follow_category": "Want to hear about new articles in this category?" + "published": "Published" } }, "@vitnode/blog:posts": "Articles", diff --git a/plugins/blog/src/pages/post-page.tsx b/plugins/blog/src/pages/post-page.tsx index 43f749823..fc4c1ac8c 100644 --- a/plugins/blog/src/pages/post-page.tsx +++ b/plugins/blog/src/pages/post-page.tsx @@ -10,14 +10,12 @@ import { contentDeliveryPageHead, } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; -import { FollowSubjectButton } from "@vitnode/core/tanstack/notifications"; import { useTranslations } from "use-intl"; import { z } from "zod"; import { CONFIG_PLUGIN } from "@/const"; const zodBlogPost = z.object({ - categoryId: z.array(z.number()).catch([]), content: z.string(), coverImage: zodContentFileDescriptor.nullable(), coverImageAlt: z.string().nullable(), @@ -102,17 +100,6 @@ const BlogPostPage = ({ <h1 className="text-foreground text-3xl font-semibold tracking-tight text-balance md:text-4xl"> {item.title} </h1> - - {item.categoryId[0] === undefined ? null : ( - <div className="flex flex-wrap items-center gap-3"> - <p className="text-muted-foreground text-sm leading-relaxed"> - {t("follow_category")} - </p> - <FollowSubjectButton - subject={{ id: item.categoryId[0], type: "blog.category" }} - /> - </div> - )} </header> {item.coverImage ? ( From 64237fbddcf263a526d8bcd332b3408f2b9b4a06 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axen@vitnode.com> Date: Sun, 4 Oct 2026 18:23:03 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(cron):=20=E2=9C=A8=20add=20cron=20heal?= =?UTF-8?q?th=20monitoring=20and=20status=20reporting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced `cron-health.ts` for managing cron job timing and health status. - Removed deprecated `is-cron-stale.ts`. - Updated `config.ts` to include `isCronSecretRejected` for secret validation. - Enhanced localization in `en.json` for cron job statuses and health messages. - Modified admin cron queries to include health checks. - Implemented `CronSchedulerStatus` component for displaying cron health. - Updated `CronTableContent` to show job statuses (on schedule, overdue, waiting). - Improved integration and notification components to reflect cron job health. --- apps/web/content/docs/dev/cron/index.mdx | 18 ++ .../docs/dev/notifications/admincp.mdx | 5 + apps/web/src/locales/@vitnode/core/pl.json | 42 ++- packages/vitnode/src/api/lib/cron.test.ts | 72 +++++ packages/vitnode/src/api/lib/cron.ts | 25 +- .../api/middlewares/cron-auth.middleware.ts | 7 +- .../src/api/models/notifications/admin.ts | 17 +- .../admin/advanced/cron/cron.admin.module.ts | 3 +- .../admin/advanced/cron/routes/get.route.ts | 19 +- .../advanced/cron/routes/health.route.ts | 51 +++ .../admin/debug/routes/integrations.route.ts | 32 +- .../modules/cron/helpers/load-cron-health.ts | 17 + packages/vitnode/src/components/ui/sheet.tsx | 4 +- .../vitnode/src/lib/api/cron-health.test.ts | 139 +++++++++ packages/vitnode/src/lib/api/cron-health.ts | 58 ++++ packages/vitnode/src/lib/api/is-cron-stale.ts | 13 - packages/vitnode/src/lib/config.ts | 4 + packages/vitnode/src/locales/en.json | 53 +++- .../vitnode/src/tanstack/admin/cron/query.ts | 3 + .../vitnode/src/tanstack/admin/cron/route.tsx | 16 +- .../src/tanstack/admin/cron/screen.tsx | 14 +- .../views/core/advanced/cron/cron-query.ts | 36 ++- .../cron/cron-scheduler-status.test.tsx | 110 +++++++ .../advanced/cron/cron-scheduler-status.tsx | 124 ++++++++ .../core/advanced/cron/cron-table-content.tsx | 65 +++- .../integrations/integrations-content.tsx | 32 +- .../system/integrations/integrations-query.ts | 1 + .../system/notifications/activity-content.tsx | 295 ++++++++++++++++-- 28 files changed, 1151 insertions(+), 124 deletions(-) create mode 100644 packages/vitnode/src/api/lib/cron.test.ts create mode 100644 packages/vitnode/src/api/modules/admin/advanced/cron/routes/health.route.ts create mode 100644 packages/vitnode/src/api/modules/cron/helpers/load-cron-health.ts create mode 100644 packages/vitnode/src/lib/api/cron-health.test.ts create mode 100644 packages/vitnode/src/lib/api/cron-health.ts delete mode 100644 packages/vitnode/src/lib/api/is-cron-stale.ts create mode 100644 packages/vitnode/src/views/admin/views/core/advanced/cron/cron-scheduler-status.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/advanced/cron/cron-scheduler-status.tsx diff --git a/apps/web/content/docs/dev/cron/index.mdx b/apps/web/content/docs/dev/cron/index.mdx index 28181547b..ebc435747 100644 --- a/apps/web/content/docs/dev/cron/index.mdx +++ b/apps/web/content/docs/dev/cron/index.mdx @@ -85,6 +85,24 @@ Manage and inspect cron jobs in the AdminCP at **Core → Advanced → Cron Jobs - View status, schedules, and last execution logs. - Click **Run Now** to execute any job manually on demand. +### Health warnings + +Cron fails quietly by nature: nobody notices a job that didn't run. So VitNode watches for it. + +A job is **overdue** when its next scheduled run is more than 15 minutes in the past. Never-run jobs count from the moment they were registered. + +The scheduler card at the top of the page sums it up: + +| What you see | What it means | +| --- | --- | +| Red **Scheduler stopped** | A job is overdue and nothing has run since. The scheduler is stopped or misconfigured, and the queue isn't draining either. | +| Amber **N cron jobs are overdue** | The scheduler ticks, but some jobs aren't finishing. A job that throws keeps its last successful run, so look for its error in the server logs. | +| Green **All jobs on schedule** | Everything ran on time. Go make a coffee. | + +Next to it you get the last run, the next run and how many jobs are registered. The table below lists every job with an **Overdue**, **On schedule** or **Waiting for first run** status. + +The same check powers the **Cron Jobs** and **Queue Tasks** cards at **Core → System → Integrations**. + --- ## `buildCron` Options diff --git a/apps/web/content/docs/dev/notifications/admincp.mdx b/apps/web/content/docs/dev/notifications/admincp.mdx index 7ff649d9d..7ac11264f 100644 --- a/apps/web/content/docs/dev/notifications/admincp.mdx +++ b/apps/web/content/docs/dev/notifications/admincp.mdx @@ -37,6 +37,11 @@ period before it and drawn as a small trend line: 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 diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 224836a05..c4701c40d 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -59,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 <date></date>. 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ź <docs>konfigurację cron</docs> i logi serwera.", + "title": "Harmonogram zatrzymany" + } + }, "list": { "actions": { "runNow": { @@ -70,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" }, @@ -672,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 <date></date>. 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ę.", @@ -719,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.", 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<typeof vi.spyOn>; + + 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/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/models/notifications/admin.ts b/packages/vitnode/src/api/models/notifications/admin.ts index e50276a0c..0c2feae1b 100644 --- a/packages/vitnode/src/api/models/notifications/admin.ts +++ b/packages/vitnode/src/api/models/notifications/admin.ts @@ -8,7 +8,7 @@ import { isNotificationInAppAvailable, isNotificationLockedForMembers, } from "@/api/lib/notifications/preferences"; -import { core_cron } from "@/database/cron"; +import { loadCronHealth } from "@/api/modules/cron/helpers/load-cron-health"; import { core_notification_deliveries, core_notification_events, @@ -17,7 +17,6 @@ import { core_notifications, } from "@/database/notifications"; import { core_queue } from "@/database/queue"; -import { isCronStale } from "@/lib/api/is-cron-stale"; import type { NotificationsContext } from "./shared"; @@ -55,7 +54,7 @@ export const getNotificationsOverview = async (c: NotificationsContext) => { deliveryRows, eventRows, [oldestPending], - [cron], + cronHealth, [inbox], [members], ] = await Promise.all([ @@ -88,13 +87,7 @@ export const getNotificationsOverview = async (c: NotificationsContext) => { ) .orderBy(core_notification_events.createdAt) .limit(1), - db - .select({ - lastActivity: sql< - null | string - >`max(coalesce(${core_cron.lastRun}, ${core_cron.createdAt}))`, - }) - .from(core_cron), + loadCronHealth(db), db .select({ items: count(), @@ -122,9 +115,7 @@ export const getNotificationsOverview = async (c: NotificationsContext) => { }, health: { cronActive: core.hasCronAdapter, - cronStale: isCronStale( - cron?.lastActivity ? new Date(cron.lastActivity) : null, - ), + cronStale: cronHealth.stale, deliveries: countBy(deliveryRows), events: countBy(eventRows), oldestPendingEventAt: oldestPending?.createdAt ?? null, 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<Date | null>`max(${core_cron.lastRun})`, - lastActivity: sql<Date | null>`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/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<typeof drizzle>, +): Promise<CronHealth> => + 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/components/ui/sheet.tsx b/packages/vitnode/src/components/ui/sheet.tsx index a5ae784aa..ece5b870a 100644 --- a/packages/vitnode/src/components/ui/sheet.tsx +++ b/packages/vitnode/src/components/ui/sheet.tsx @@ -26,7 +26,7 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { return ( <SheetPrimitive.Backdrop className={cn( - "data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs", + "fixed inset-0 z-50 bg-black/10 transition-opacity duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs", className, )} data-slot="sheet-overlay" @@ -52,7 +52,7 @@ function SheetContent({ <SheetOverlay /> <SheetPrimitive.Popup className={cn( - "bg-popover text-popover-foreground data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10 fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-e data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-s data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm", + "bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition-[opacity,translate] duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-10 data-[side=bottom]:data-starting-style:translate-y-10 data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-e data-[side=left]:data-ending-style:-translate-x-10 data-[side=left]:data-starting-style:-translate-x-10 data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-s data-[side=right]:data-ending-style:translate-x-10 data-[side=right]:data-starting-style:translate-x-10 data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:-translate-y-10 data-[side=top]:data-starting-style:-translate-y-10 data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm", className, )} data-side={side} 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..808f92d22 --- /dev/null +++ b/packages/vitnode/src/lib/api/cron-health.ts @@ -0,0 +1,58 @@ +import { getNextCronRunDate } from "./get-next-cron-run-date"; + +export 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<Date | null>( + (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<Date | null>( + (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 1abdaf0b3..807b8c5fb 100644 --- a/packages/vitnode/src/lib/config.ts +++ b/packages/vitnode/src/lib/config.ts @@ -35,3 +35,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/locales/en.json b/packages/vitnode/src/locales/en.json index 28307c6d5..3346061cf 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -1705,8 +1705,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", @@ -1721,7 +1726,32 @@ "label": "Run Job Now", "success": "Cron job executed successfully." } - } + }, + "search": "Search jobs…" + }, + "health": { + "stale": { + "title": "Scheduler stopped", + "desc": "Nothing has run since <date></date>. 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 <docs>cron setup</docs> 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": { @@ -2044,7 +2074,14 @@ "skipped": "Emails skipped", "vs_previous": "vs previous period", "no_previous": "Nothing to compare yet", - "trend": "{label} over the selected range" + "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", @@ -2209,7 +2246,7 @@ "status": { "active": "Active", "inactive": "Inactive", - "warning": "Unreachable" + "warning": "Needs attention" }, "ai": { "title": "AI", @@ -2298,8 +2335,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 <date></date>. 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/tanstack/admin/cron/query.ts b/packages/vitnode/src/tanstack/admin/cron/query.ts index 37a8fbdb0..000037286 100644 --- a/packages/vitnode/src/tanstack/admin/cron/query.ts +++ b/packages/vitnode/src/tanstack/admin/cron/query.ts @@ -10,6 +10,7 @@ import type { } from "@/views/admin/views/core/advanced/cron/run-action/run-cron"; import { + cronHealthQueryOptions, cronQueryOptions, cronQueryRoot, fetchCronPage, @@ -19,6 +20,8 @@ import { runCronInBrowser } from "@/views/admin/views/core/advanced/cron/run-act export const cronQuery = ({ params }: { params: CronParams }) => cronQueryOptions({ fetchPage: fetchCronPage, params }); +export const cronHealthQuery = () => cronHealthQueryOptions(); + export const invalidateCron = async (queryClient: QueryClient): Promise<void> => await queryClient.invalidateQueries({ queryKey: cronQueryRoot }); diff --git a/packages/vitnode/src/tanstack/admin/cron/route.tsx b/packages/vitnode/src/tanstack/admin/cron/route.tsx index 4068e9b9e..7cac07979 100644 --- a/packages/vitnode/src/tanstack/admin/cron/route.tsx +++ b/packages/vitnode/src/tanstack/admin/cron/route.tsx @@ -4,7 +4,7 @@ import type { CronParams } from "@/views/admin/views/core/advanced/cron/cron-que import type { AdminScreenContext } from "../screen"; import { requireAdminPermission } from "../screen"; -import { cronQuery } from "./query"; +import { cronHealthQuery, cronQuery } from "./query"; export const ADMIN_CRON_NAMESPACES = [ "admin.advanced.cron", @@ -34,10 +34,16 @@ export const loadAdminCronRoute = async ({ }): Promise<AdminCronRouteData> => { requireAdminPermission(adminAccess, CRON_VIEW_PERMISSION); - await queryClient.query({ - ...cronQuery({ params }), - staleTime: "static", - }); + await Promise.all([ + queryClient.query({ + ...cronQuery({ params }), + staleTime: "static", + }), + queryClient.query({ + ...cronHealthQuery(), + 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..5fc5fa155 100644 --- a/packages/vitnode/src/tanstack/admin/cron/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/cron/screen.tsx @@ -5,6 +5,7 @@ import type { DataTableNavigation } from "@/components/table/navigation"; import { DataTableNavigationProvider } from "@/components/table/navigation"; import { PageTitle } from "@/components/ui/page-title"; +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"; @@ -12,7 +13,7 @@ import type { AdminCronRouteData } from "./route"; import type { CronRouteSearch, UncheckedCronSearch } from "./route-search"; import { RouteMessages } from "../../i18n/route-messages"; -import { cronQuery, useCronRunCallback } from "./query"; +import { cronHealthQuery, cronQuery, useCronRunCallback } from "./query"; import { ADMIN_CRON_NAMESPACES } from "./route"; import { cronSearchFrom, cronSearchParams } from "./route-search"; @@ -29,6 +30,7 @@ export const AdminCronRouteContent = ({ title, }: AdminCronRouteProps) => { const { data } = useSuspenseQuery(cronQuery({ params })); + const { data: health } = useSuspenseQuery(cronHealthQuery()); const onRun = useCronRunCallback(); const navigation = React.useMemo<DataTableNavigation>( @@ -49,9 +51,13 @@ export const AdminCronRouteContent = ({ <div className="p-6"> <PageTitle desc={description} h1={title} /> - <DataTableNavigationProvider value={navigation}> - <CronTableContent data={data} onRun={onRun} /> - </DataTableNavigationProvider> + <div className="flex flex-col gap-4"> + <CronSchedulerStatus health={health} /> + + <DataTableNavigationProvider value={navigation}> + <CronTableContent data={data} onRun={onRun} /> + </DataTableNavigationProvider> + </div> </div> </RouteMessages> ); 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<CronOrderBy> = { orderBy: CRON_ORDER_BY, + search: true, }; export type CronParams = AdminTableParams<CronOrderBy>; @@ -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<CronHealth> => { + 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( + <IntlProvider + locale="en" + messages={{}} + now={new Date("2026-10-04T15:35:00Z")} + timeZone="UTC" + > + {ui} + </IntlProvider>, + ); + +const health = (overrides: Partial<CronHealth>): 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(<CronSchedulerStatus health={health({})} />); + + 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(<CronSchedulerStatus health={health({ overdueJobs: 2 })} />); + + 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( + <CronSchedulerStatus + health={health({ + lastRun: "2026-09-06T16:41:00Z", + overdueJobs: 5, + stale: true, + })} + />, + ); + + 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( + <CronSchedulerStatus + health={health({ + active: false, + lastRun: null, + nextRun: null, + overdueJobs: 1, + stale: true, + })} + />, + ); + + 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( + <CronSchedulerStatus + health={health({ overdueJobs: 5, secretRejected: true, stale: true })} + />, + ); + + 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..ba4c546ba --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-scheduler-status.tsx @@ -0,0 +1,124 @@ +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 DocsLink = ({ children }: { children: React.ReactNode }) => ( + <a + className="text-primary underline-offset-4 hover:underline" + href={DOCS_URLS.cron} + rel="noopener noreferrer" + target="_blank" + > + {children} + </a> +); + +const Fact = ({ + children, + label, +}: { + children: React.ReactNode; + label: string; +}) => ( + <div className="flex flex-col gap-1"> + <dt className="text-muted-foreground">{label}</dt> + <dd className="font-medium tabular-nums">{children}</dd> + </div> +); + +export const CronSchedulerStatus = ({ health }: { health: CronHealth }) => { + const t = useTranslations("admin.advanced.cron.health"); + const state: SchedulerState = health.stale + ? "stopped" + : health.overdueJobs > 0 + ? "overdue" + : "ok"; + const Icon = STATE_ICON[state]; + const lastRun = health.lastRun; + + return ( + <section + aria-labelledby="cron-scheduler-status" + className="bg-card text-card-foreground ring-foreground/10 flex flex-col gap-5 rounded-xl p-5 shadow-xs ring-1 md:flex-row md:items-center md:justify-between" + data-state={state} + > + <div className="flex items-start gap-4"> + <span + className={cn( + "flex size-10 shrink-0 items-center justify-center rounded-full [&_svg]:size-5", + state === "stopped" && "bg-destructive/10 text-destructive", + state === "overdue" && "bg-warn/10 text-warn", + state === "ok" && "bg-success/10 text-success", + )} + > + <Icon aria-hidden /> + </span> + + <div className="flex min-w-0 flex-col gap-1"> + <h2 + className="text-lg font-semibold text-balance" + id="cron-scheduler-status" + > + {state === "stopped" + ? t("stale.title") + : state === "overdue" + ? t("overdue.title", { count: health.overdueJobs }) + : t("ok.title")} + </h2> + + <p className="text-muted-foreground max-w-prose text-sm leading-relaxed text-pretty"> + {state === "stopped" + ? lastRun + ? t.rich("stale.desc", { + date: () => <DateFormat date={lastRun} showFullDate />, + }) + : t("stale.desc_never") + : state === "overdue" + ? t("overdue.desc") + : t("ok.desc")} + </p> + + {(state === "stopped" || health.secretRejected) && ( + <p className="text-sm leading-relaxed text-pretty"> + {health.secretRejected ? ( + <>{t("secret_rejected")} </> + ) : ( + !health.active && <>{t("no_adapter")} </> + )} + {t.rich("stale.help", { + docs: text => <DocsLink>{text}</DocsLink>, + })} + </p> + )} + </div> + </div> + + <dl className="grid shrink-0 grid-cols-3 gap-4 text-sm md:gap-6"> + <Fact label={t("facts.last_run")}> + {lastRun ? <DateFormat date={lastRun} /> : t("facts.never")} + </Fact> + <Fact label={t("facts.next_run")}> + {health.nextRun && !health.stale ? ( + <DateFormat date={health.nextRun} /> + ) : ( + "—" + )} + </Fact> + <Fact label={t("facts.jobs")}>{health.jobs}</Fact> + </dl> + </section> + ); +}; 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..a574a84b3 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,3 +1,4 @@ +import { cn } from "cn"; import { useTranslations } from "use-intl"; import { DateFormat } from "@/components/date-format"; @@ -8,6 +9,21 @@ 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 STATUS_DOT: Record<CronJobStatus, string> = { + on_schedule: "bg-success", + overdue: "bg-warn", + waiting: "bg-muted-foreground/50", +}; + export const CronTableContent = ({ data, onRun, @@ -35,11 +51,54 @@ export const CronTableContent = ({ </div> ), }, - { accessorKey: "pluginId", header: t("list.pluginId") }, - { accessorKey: "module", header: t("list.module") }, + { + id: "status", + header: t("list.status.title"), + cell: ({ row }) => { + const status = jobStatus(row); + + return ( + <span className="inline-flex items-center gap-1.5 whitespace-nowrap"> + <span + aria-hidden + className={cn( + "size-2 shrink-0 rounded-full", + STATUS_DOT[status], + )} + /> + <span + className={cn( + status === "overdue" + ? "text-warn" + : "text-muted-foreground", + )} + > + {t(`list.status.${status}`)} + </span> + </span> + ); + }, + }, + { + accessorKey: "pluginId", + header: t("list.plugin"), + cell: ({ row }) => ( + <div className="flex flex-col"> + <span>{row.pluginId}</span> + <span className="text-muted-foreground text-sm"> + {row.module} + </span> + </div> + ), + }, { accessorKey: "schedule", header: t("list.schedule"), + cell: ({ row }) => ( + <code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs whitespace-nowrap"> + {row.schedule} + </code> + ), }, { accessorKey: "lastRun", @@ -82,6 +141,8 @@ export const CronTableContent = ({ }, }} pageInfo={data.pageInfo} + search + searchPlaceholder={t("list.search")} /> ); }; 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..a7afc9def 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"; @@ -51,11 +52,16 @@ export const IntegrationsContent = ({ ? "active" : "inactive"; - const cronStatus: IntegrationStatus = !data.cron.active - ? "inactive" - : data.cron.stale || !data.cron.secure - ? "warning" - : "active"; + const cronHasRun = data.cron.active || data.cron.lastRun !== null; + const cronFailing = cronHasRun && data.cron.overdueJobs > 0; + const cronStatus: IntegrationStatus = cronFailing + ? "warning" + : !data.cron.active + ? "inactive" + : !data.cron.secure + ? "warning" + : "active"; + const cronLastRun = data.cron.lastRun; return ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> @@ -141,12 +147,20 @@ export const IntegrationsContent = ({ href={DOCS_URLS.cron} Icon={ClockIcon} meta={ - !data.cron.active ? ( - <span>{t("cron.not_configured")}</span> - ) : data.cron.stale ? ( + cronFailing && data.cron.stale ? ( + <span className="text-destructive"> + {cronLastRun + ? t.rich("cron.stale", { + date: () => <DateFormat date={cronLastRun} />, + }) + : t("cron.stale_never")} + </span> + ) : cronFailing ? ( <span className="text-amber-700 dark:text-amber-400"> - {t("cron.stale")} + {t("cron.overdue", { count: data.cron.overdueJobs })} </span> + ) : !data.cron.active ? ( + <span>{t("cron.not_configured")}</span> ) : !data.cron.secure ? ( <span className="text-amber-700 dark:text-amber-400"> {t("cron.insecure")} diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-query.ts b/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-query.ts index 65493a0df..7603b9204 100644 --- a/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-query.ts +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-query.ts @@ -23,6 +23,7 @@ export interface AdminIntegrations { active: boolean; jobs: number; lastRun: null | string; + overdueJobs: number; secure: boolean; stale: boolean; }; diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/activity-content.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/activity-content.tsx index ae10a0f59..8a3a4a999 100644 --- a/packages/vitnode/src/views/admin/views/core/system/notifications/activity-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/activity-content.tsx @@ -9,7 +9,17 @@ import { useReducedMotion, } from "motion/react"; import React from "react"; -import { Area, AreaChart } from "recharts"; +import { createPortal } from "react-dom"; +import { + Area, + AreaChart, + Tooltip, + useActiveTooltipCoordinate, + useActiveTooltipLabel, + useChartWidth, + useIsTooltipActive, + XAxis, +} from "recharts"; import { useFormatter, useTimeZone, useTranslations } from "use-intl"; import type { ChartConfig } from "@/components/ui/chart"; @@ -101,45 +111,270 @@ const Swap = ({ ); }; +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 ( + <div className="bg-popover text-popover-foreground ring-foreground/10 flex w-max max-w-48 flex-col gap-0.5 rounded-md px-2.5 py-1.5 text-xs leading-snug shadow-md ring-1"> + <span className="text-muted-foreground">{when}</span> + <span className="flex items-center gap-1.5 font-medium tabular-nums"> + <span + aria-hidden + className="size-2 shrink-0 rounded-full" + style={{ backgroundColor: TONE[metric] }} + /> + {value} + </span> + </div> + ); +}; + +const TOOLTIP_EASE = [0.32, 0.72, 0, 1] as const; + +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 | ResizeObserver>(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 ( + <motion.div + animate={{ + opacity: open ? 1 : 0, + scale: open ? 1 : 0.97, + x: left, + y: open ? 0 : 2, + }} + aria-hidden={!open} + className="pointer-events-none absolute bottom-full left-0 z-10 mb-1.5 origin-bottom" + initial={ + shouldReduceMotion ? false : { opacity: 0, scale: 0.97, x: left, y: 2 } + } + ref={measure} + transition={ + shouldReduceMotion + ? { duration: 0 } + : { + default: open + ? { duration: 0.15, ease: TOOLTIP_EASE } + : { duration: 0.1, ease: "easeIn" }, + x: glide + ? { duration: 0.14, ease: TOOLTIP_EASE } + : { duration: 0 }, + } + } + > + {children} + </motion.div> + ); +}; + +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( + <SparkTooltipLayer + glide={shown.glide && glideAllowed} + open={shown.open} + width={width} + x={shown.x} + > + <SparkTooltip metric={metric} point={point} unit={unit} /> + </SparkTooltipLayer>, + 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<HTMLDivElement | null>(null); const color = TONE[metric]; const config = { value: { color } } satisfies ChartConfig; - const data = points.map(point => ({ value: valueOf(point, metric) })); + const data = points.map(point => ({ + key: point.key, + value: valueOf(point, metric), + })); return ( - <ChartContainer - aria-hidden - className="aspect-auto h-10 w-full" - config={config} - > - <AreaChart data={data} margin={{ bottom: 2, left: 0, right: 0, top: 2 }}> - <defs> - <linearGradient id={`spark-${id}`} x1="0" x2="0" y1="0" y2="1"> - <stop offset="0%" stopColor={color} stopOpacity={0.2} /> - <stop offset="100%" stopColor={color} stopOpacity={0} /> - </linearGradient> - </defs> - <Area - animationDuration={300} - animationEasing="ease-out" - dataKey="value" - fill={`url(#spark-${id})`} - isAnimationActive={animate} - stroke={color} - strokeWidth={1.5} - type="monotone" - /> - </AreaChart> - </ChartContainer> + <div className="relative" ref={setLayer}> + <ChartContainer + className="aspect-auto h-10 w-full" + config={config} + onKeyDown={() => { + setInput("keyboard"); + }} + onPointerMove={() => { + setInput("pointer"); + }} + > + <AreaChart + data={data} + margin={{ bottom: 2, left: 0, right: 0, top: 2 }} + title={t("trend", { label })} + > + <defs> + <linearGradient id={`spark-${id}`} x1="0" x2="0" y1="0" y2="1"> + <stop offset="0%" stopColor={color} stopOpacity={0.2} /> + <stop offset="100%" stopColor={color} stopOpacity={0} /> + </linearGradient> + </defs> + <XAxis dataKey="key" hide /> + <Tooltip + content={() => null} + cursor={{ stroke: "var(--border)", strokeWidth: 1 }} + isAnimationActive={false} + /> + <SparkTooltipPortal + container={layer} + glideAllowed={input === "pointer"} + metric={metric} + points={points} + unit={unit} + /> + <Area + activeDot={{ + fill: color, + r: 3, + stroke: "var(--card)", + strokeWidth: 2, + }} + animationDuration={300} + animationEasing="ease-out" + dataKey="value" + fill={`url(#spark-${id})`} + isAnimationActive={animate} + stroke={color} + strokeWidth={1.5} + type="monotone" + /> + </AreaChart> + </ChartContainer> + </div> ); }; @@ -221,7 +456,13 @@ const StatTile = ({ previous={valueOf(stats.previous, metric)} /> </Swap> - <Sparkline animate={animate} metric={metric} points={stats.points} /> + <Sparkline + animate={animate} + label={label} + metric={metric} + points={stats.points} + unit={stats.unit} + /> </CardContent> </Card> ); From 8096fb833dfa2de910b981db30af265242b841fb Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axen@vitnode.com> Date: Sun, 4 Oct 2026 18:59:39 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat(notifications):=20=E2=9C=A8=20Enhance?= =?UTF-8?q?=20notification=20settings=20and=20email=20preferences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor notification item components for better readability and maintainability. - Introduce `RevealPanel` component for smoother transitions in notification settings. - Update email notification modes to use a centralized type `NotificationEmailMode`. - Improve time zone handling by adding `timeZoneLabel` utility for better user experience. - Clean up unused comments and improve code clarity across notification-related files. --- apps/web/src/content-modules.gen.ts | 2 - packages/nodemailer/src/index.ts | 6 +- packages/resend/src/index.ts | 4 +- .../src/api/lib/notifications/core-types.ts | 6 - .../lib/notifications/digest-period.test.ts | 12 +- .../api/lib/notifications/digest-period.ts | 26 +- .../src/api/lib/notifications/preferences.ts | 25 +- .../src/api/lib/notifications/registry.ts | 83 +--- .../vitnode/src/api/lib/notifications/safe.ts | 10 - packages/vitnode/src/api/lib/plugin.ts | 20 +- .../src/api/middlewares/global.middleware.ts | 4 - packages/vitnode/src/api/models/email.ts | 13 +- .../src/api/models/notifications/admin.ts | 57 ++- .../src/api/models/notifications/danger.ts | 99 ++--- .../api/models/notifications/email-queue.ts | 5 - .../notifications/email.integration.test.ts | 12 +- .../src/api/models/notifications/email.ts | 113 +++--- .../notifications/fanout.integration.test.ts | 3 - .../fanout.load.integration.test.ts | 18 +- .../src/api/models/notifications/fanout.ts | 93 ++--- .../notifications/inbox.integration.test.ts | 48 +-- .../src/api/models/notifications/inbox.ts | 146 +++---- .../src/api/models/notifications/index.ts | 43 +-- .../api/models/notifications/maintenance.ts | 57 ++- .../api/models/notifications/preferences.ts | 18 +- .../src/api/models/notifications/publish.ts | 25 +- .../src/api/models/notifications/render.ts | 20 +- .../src/api/models/notifications/run-now.ts | 10 - .../src/api/models/notifications/shared.ts | 43 +-- .../src/api/models/notifications/stats.ts | 21 +- .../notifications/routes/danger.route.ts | 34 +- .../notifications/routes/deliveries.route.ts | 17 +- .../notifications/routes/overview.route.ts | 11 +- .../notifications/routes/settings.route.ts | 4 +- .../admin/routes/notifications.route.ts | 5 +- .../notifications/routes/mark.route.ts | 1 - .../notifications/routes/preferences.route.ts | 2 +- .../src/api/modules/notifications/schema.ts | 11 +- .../notifications/tasks/notification-tasks.ts | 2 - .../queue/helpers/process-queue-tasks.ts | 42 +-- .../api/modules/users/routes/session.route.ts | 13 +- .../vitnode/src/components/reveal-panel.tsx | 44 +++ .../vitnode/src/database/notifications.ts | 28 -- packages/vitnode/src/emails/notification.tsx | 7 - packages/vitnode/src/lib/api/cron-health.ts | 2 +- .../vitnode/src/lib/notifications/types.ts | 16 - .../src/pages/admin/system/notifications.tsx | 5 - packages/vitnode/src/pages/notifications.tsx | 1 - .../vitnode/src/tanstack/admin/cron/query.ts | 3 - .../vitnode/src/tanstack/admin/cron/route.tsx | 6 +- .../src/tanstack/admin/cron/screen.tsx | 5 +- .../src/tanstack/admin/notifications/query.ts | 23 +- .../tanstack/admin/notifications/route.tsx | 7 - .../src/tanstack/notifications/actions.ts | 17 +- .../src/tanstack/notifications/bell.test.tsx | 9 +- .../src/tanstack/notifications/bell.tsx | 2 - .../src/tanstack/notifications/breadcrumb.tsx | 1 - .../src/tanstack/notifications/cache.ts | 1 - .../src/tanstack/notifications/namespaces.ts | 4 - .../tanstack/notifications/route-search.ts | 1 - .../src/tanstack/notifications/screen.tsx | 16 +- .../notifications/settings-screen.tsx | 11 +- .../notifications/state-sync.test.tsx | 6 +- .../src/tanstack/notifications/state-sync.tsx | 47 +-- packages/vitnode/src/tests/notifications.ts | 7 +- packages/vitnode/src/tests/postgres.ts | 18 +- .../advanced/cron/cron-scheduler-status.tsx | 105 ++++-- .../core/advanced/cron/cron-table-content.tsx | 8 +- .../integrations/integrations-content.tsx | 126 ++++--- .../system/notifications/activity-content.tsx | 15 +- .../notifications/notifications-mutations.ts | 14 - .../notifications/notifications-query.ts | 2 - .../notifications/settings-sheet-content.tsx | 2 +- .../system/notifications/types-content.tsx | 349 ++++++++--------- .../settings/overview/time-zone-editor.tsx | 12 +- .../auth/settings/overview/time-zone-row.tsx | 3 +- .../settings/overview/time-zone-update.ts | 4 +- .../layouts/theme/header/header-content.tsx | 1 - .../src/views/notifications/coalesce.ts | 5 - .../views/notifications/notification-item.tsx | 104 +++-- .../notification-state-store.test.ts | 1 - .../notifications/notification-state-store.ts | 30 +- .../notifications/notifications-actions.ts | 13 +- .../notifications-bell-content.tsx | 10 +- .../notifications/notifications-query.ts | 21 +- .../notifications/settings/email-channel.ts | 13 +- .../notifications/settings/type-item.tsx | 355 ++++++++++-------- packages/vitnode/src/ws/notifications.ts | 9 +- 88 files changed, 1098 insertions(+), 1575 deletions(-) delete mode 100644 packages/vitnode/src/api/models/notifications/run-now.ts create mode 100644 packages/vitnode/src/components/reveal-panel.tsx diff --git a/apps/web/src/content-modules.gen.ts b/apps/web/src/content-modules.gen.ts index 3d7b6a31b..eee602ba3 100644 --- a/apps/web/src/content-modules.gen.ts +++ b/apps/web/src/content-modules.gen.ts @@ -1,5 +1,3 @@ -/* eslint-disable */ - // This file is generated by VitNode. Do not edit it, and do not format it. // // It is rewritten by the `vitnode:plugin-routes` Vite plugin on every diff --git a/packages/nodemailer/src/index.ts b/packages/nodemailer/src/index.ts index d58600c46..77148a606 100644 --- a/packages/nodemailer/src/index.ts +++ b/packages/nodemailer/src/index.ts @@ -48,11 +48,7 @@ export const NodemailerEmailAdapter = ({ html, }); - // SMTP has no idempotency: a crash after the server accepted a message - // and before this returned can mean a retry delivers it twice. - return { - id: typeof info.messageId === "string" ? info.messageId : undefined, - }; + return { id: info.messageId }; }, }; }; diff --git a/packages/resend/src/index.ts b/packages/resend/src/index.ts index a0feda3c7..fcde902ff 100644 --- a/packages/resend/src/index.ts +++ b/packages/resend/src/index.ts @@ -31,8 +31,6 @@ export const ResendEmailAdapter = ({ replyTo, html, }, - // Resend drops a second send with the same key for 24 hours, which is - // what makes a retry after an unconfirmed send safe. idempotencyKey ? { idempotencyKey } : undefined, ); @@ -40,7 +38,7 @@ export const ResendEmailAdapter = ({ throw new Error(`[${error.name}]: ${error.message}`); } - return { id: data?.id }; + return { id: data.id }; }, }; }; diff --git a/packages/vitnode/src/api/lib/notifications/core-types.ts b/packages/vitnode/src/api/lib/notifications/core-types.ts index 6bae957c9..1fd6ee249 100644 --- a/packages/vitnode/src/api/lib/notifications/core-types.ts +++ b/packages/vitnode/src/api/lib/notifications/core-types.ts @@ -2,11 +2,6 @@ import { z } from "zod"; import { buildNotificationType } from "./registry"; -/** - * A message an administrator sends a user from the dashboard widget. Before - * the Notifications Center it was a toast that vanished on reload; now it - * also lands in the inbox. - */ export const adminMessageNotification = buildNotificationType({ id: "core.admin_message", version: 1, @@ -18,7 +13,6 @@ export const adminMessageNotification = buildNotificationType({ label: "core.notifications.types.admin_message.label", description: "core.notifications.types.admin_message.description", defaults: { email: "none", inApp: true }, - // No target: the whole message is the item, so clicking it only marks it read. present: ({ data }) => ({ body: data.description, title: data.title }), }); diff --git a/packages/vitnode/src/api/lib/notifications/digest-period.test.ts b/packages/vitnode/src/api/lib/notifications/digest-period.test.ts index a2bbad66c..334b4b9d5 100644 --- a/packages/vitnode/src/api/lib/notifications/digest-period.test.ts +++ b/packages/vitnode/src/api/lib/notifications/digest-period.test.ts @@ -21,15 +21,13 @@ describe("zonedHourToUtc", () => { ).toEqual(new Date("2026-07-15T06:00:00Z")); }); - it("moves an hour that does not exist to the end of the gap", () => { - // 02:00-03:00 on 29 March 2026 never happened in Warsaw. + 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 an hour that happens twice", () => { - // 01:00 happened twice in New York on 1 November 2026. + 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")); @@ -65,16 +63,16 @@ describe("latestEndedDigestPeriod", () => { ); }); - it("lands on the local date, not the UTC one", () => { - // 23:30 UTC on the 9th is already the 10th in Tokyo. + 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: new Date("2026-10-08T12:00:00Z"), // a Thursday + now: thursdayNoonUtc, timeZone: "UTC", weekday: 1, }); diff --git a/packages/vitnode/src/api/lib/notifications/digest-period.ts b/packages/vitnode/src/api/lib/notifications/digest-period.ts index 3192cbc41..38246a5f2 100644 --- a/packages/vitnode/src/api/lib/notifications/digest-period.ts +++ b/packages/vitnode/src/api/lib/notifications/digest-period.ts @@ -8,7 +8,6 @@ export interface LocalDate { export interface DigestPeriod { end: Date; - /** The local calendar date the digest belongs to, e.g. `2026-10-04`. */ key: string; start: Date; } @@ -54,7 +53,7 @@ interface WallClock extends LocalDate { weekday: number; } -export const wallClockAt = (instant: Date, timeZone: string): WallClock => { +const wallClockAt = (instant: Date, timeZone: string): WallClock => { const parts = Object.fromEntries( formatterFor(timeZone) .formatToParts(instant) @@ -86,12 +85,6 @@ const offsetAt = (instant: number, timeZone: string): number => { return asUtc - Math.floor(instant / 1000) * 1000; }; -/** - * The instant a local wall-clock hour happens in `timeZone`. On the night - * clocks jump forward the hour may not exist; the answer is then the first - * instant after the gap. On the night they fall back it happens twice; the - * earlier one wins. Either way each local date maps to exactly one instant. - */ export const zonedHourToUtc = ( date: LocalDate, hour: number, @@ -101,17 +94,20 @@ export const zonedHourToUtc = ( const before = offsetAt(wall - 12 * 60 * 60 * 1000, timeZone); const after = offsetAt(wall + 12 * 60 * 60 * 1000, timeZone); - const candidates = [ + 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 (candidates[0] !== undefined) return new Date(candidates[0]); + if (earliestMatchingInstant !== undefined) { + return new Date(earliestMatchingInstant); + } + + const instantClocksJumpForwardTo = wall - before; - // A gap: the wall time never happens. Use the instant clocks jump to. - return new Date(wall - before); + return new Date(instantClocksJumpForwardTo); }; const addDays = (date: LocalDate, days: number): LocalDate => { @@ -129,12 +125,6 @@ const addDays = (date: LocalDate, days: number): LocalDate => { const formatLocalDate = (date: LocalDate): string => `${date.year}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`; -/** - * The most recent digest period that has already ended at `now`. A daily digest - * ends every local day at `hour`; a weekly one on `weekday` at `hour`. Periods - * are contiguous, so on DST days a daily period is 23 or 25 hours long rather - * than skipping or repeating a day. - */ export const latestEndedDigestPeriod = ({ hour, mode, diff --git a/packages/vitnode/src/api/lib/notifications/preferences.ts b/packages/vitnode/src/api/lib/notifications/preferences.ts index 27facb862..2fa763d45 100644 --- a/packages/vitnode/src/api/lib/notifications/preferences.ts +++ b/packages/vitnode/src/api/lib/notifications/preferences.ts @@ -3,17 +3,12 @@ import type { NotificationEmailMode } from "@/lib/notifications/types"; import type { AnyNotificationTypeDefinition } from "./registry"; -/** Installation-wide policy for one type, edited in the AdminCP. */ export interface NotificationTypePolicy { - /** `false` keeps the type off email whatever users choose. */ allowEmail?: boolean; allowInApp?: boolean; allowPush?: boolean; - /** Default email mode for users who never chose one. */ email?: NotificationEmailMode; - /** `false` stops the type entirely (mandatory types ignore it). */ enabled?: boolean; - /** Default in-app setting for users who never chose one. */ inApp?: boolean; memberCanEdit?: boolean; } @@ -29,17 +24,13 @@ export const DEFAULT_NOTIFICATION_SETTINGS: NotificationGlobalSettings = { }; export interface NotificationWorkerSettings { - /** Upper bound on emails one drain run sends. */ emailBatchSize: number; - /** Emails sent in parallel within one drain run. */ emailConcurrency: number; - /** Recipients handled per fan-out transaction. */ fanoutBatchSize: number; - /** Inbox items older than this many days are removed by cleanup. */ retentionDays: number; } -export const NOTIFICATION_WORKER_LIMITS = { +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 }, @@ -77,7 +68,6 @@ export interface NotificationChannels { const OFF: NotificationChannels = { email: "none", inApp: false, push: false }; -/** Whether this type can email at all on this installation. */ export const isNotificationInAppAvailable = ({ definition, policy, @@ -104,24 +94,11 @@ export const isNotificationEmailAvailable = ({ policy, }: { definition: AnyNotificationTypeDefinition; - /** An email adapter exists and notification email is switched on. */ emailConfigured: boolean; policy?: NotificationTypePolicy; }): boolean => emailConfigured && !!definition.email && policy?.allowEmail !== false; -/** - * The single place that decides what a user receives, in this order: - * - * 1. Installation policy disables the type → nothing (mandatory types excepted). - * 2. Mandatory types → always in-app; email follows the installation default. - * 3. The user's own choice for the type, unless the type is locked for members. - * 4. The installation default for the type. - * 5. The type's own default. - * - * Email is additionally limited to what the type and installation offer, and - * never depends on the in-app choice. - */ export const resolveNotificationChannels = ({ definition, emailConfigured, diff --git a/packages/vitnode/src/api/lib/notifications/registry.ts b/packages/vitnode/src/api/lib/notifications/registry.ts index 8a984d818..b7fa16313 100644 --- a/packages/vitnode/src/api/lib/notifications/registry.ts +++ b/packages/vitnode/src/api/lib/notifications/registry.ts @@ -10,14 +10,8 @@ import type { import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types"; -/** Plain text a type renders for one inbox item. Never HTML. */ export interface NotificationPresentation { body?: string; - /** - * Where clicking the item goes: a path on this site, such as `/blog/hello`. - * Anything else - another origin, `javascript:`, `//evil.com` - is dropped - * and the item renders without a link. - */ target?: null | string; title: string; } @@ -35,12 +29,9 @@ export interface NotificationActorPreview { } export interface NotificationPresentArgs<TData> { - /** Distinct actors across every event of the item. */ actorCount: number; - /** The most recent distinct actors of the item, newest first (max 3). */ actors: NotificationActorPreview[]; data: TData; - /** Events grouped into the item, 1 when the type is not grouped. */ eventCount: number; locale: string; subject: NotificationSubject | null; @@ -51,60 +42,35 @@ export interface NotificationAccessArgs<TData> { c: Context<EnvVitNode>; data: TData; subject: NotificationSubject | null; - /** The candidates to check - a bounded batch, never the whole audience. */ userIds: number[]; } export interface NotificationGroupingConfig<TData> { - /** - * What makes two events "the same thing". Defaults to the subject, and an - * event without a subject (or a `null` key) is never grouped. - */ key?: (args: { data: TData; subject: NotificationSubject | null; }) => null | string; - /** Events landing in the same window join one inbox item. */ windowMinutes: number; } export interface NotificationTypeDefinition<TData = Record<string, unknown>> { - /** - * Returns which of `userIds` may see this notification. Called in bounded - * batches during fan-out, again before an email goes out, and for every item - * a user lists - so it should answer for many users with one query. - */ access?: ( args: NotificationAccessArgs<TData>, ) => number[] | Promise<number[]>; category: string; defaults: { email: NotificationEmailMode; inApp: boolean }; - /** Message key of the one-line description shown in preferences. */ description?: string; - /** - * Turns on the email channel. `true` reuses `present` for the email; a - * function words it separately. - */ email?: | ((args: NotificationPresentArgs<TData>) => NotificationEmailPresentation) | boolean; grouping?: NotificationGroupingConfig<TData>; - /** Plugin-scoped, dot-separated id such as `blog.post_published`. */ id: string; - /** Message key of the preference label. */ label: string; - /** - * Account and security messages. Always shown in-app, never muted, and the - * user cannot opt out of its email default. - */ mandatory?: boolean; - /** Upgrades data stored by an older `version` before it is parsed. */ migrate?: (data: unknown, fromVersion: number) => unknown; present: (args: NotificationPresentArgs<TData>) => NotificationPresentation; schema: z.ZodType<TData>; - /** The subject type this notification is about, e.g. `blog.post`. */ subjectType?: string; - /** Bump when `schema` changes shape; see `migrate`. */ version: number; } @@ -117,10 +83,9 @@ export interface RegisteredNotificationType { pluginId: string; } -export type NotificationDataOf<T> = - T extends NotificationTypeDefinition<infer D> ? D : never; - 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; @@ -131,31 +96,13 @@ export class NotificationRegistryError extends Error { } } -/** - * Declares a notification type. The returned object is both what a plugin - * registers and what it passes to `publish`, so `data` is type-checked against - * the schema at every call site. - * - * @example - * ```ts - * export const commentNotification = buildNotificationType({ - * id: "blog.comment", - * version: 1, - * schema: z.object({ postId: z.number(), title: z.string() }), - * category: "social", - * label: "@vitnode/blog.notifications.comment.label", - * defaults: { inApp: true, email: "daily" }, - * present: ({ data, t }) => ({ - * title: t("@vitnode/blog.notifications.comment.title", { title: data.title }), - * target: `/blog/${data.postId}`, - * }), - * }); - * ``` - */ export function buildNotificationType<TData>( definition: NotificationTypeDefinition<TData>, ): NotificationTypeDefinition<TData> { - if (!TYPE_ID_PATTERN.test(definition.id) || definition.id.length > 100) { + 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 "-").`, ); @@ -167,7 +114,7 @@ export function buildNotificationType<TData>( ); } - if (!/^[a-z0-9_-]{1,50}$/.test(definition.category)) { + if (!CATEGORY_PATTERN.test(definition.category)) { throw new NotificationRegistryError( `Notification type "${definition.id}" has an invalid category "${definition.category}".`, ); @@ -210,10 +157,9 @@ export function buildNotificationType<TData>( return definition; } -/** Refuses the same type id registered twice, by one plugin or by two. */ export const validateNotificationTypes = ( entries: readonly RegisteredNotificationType[], -): RegisteredNotificationType[] => { +): void => { const seen = new Map<string, string>(); for (const entry of entries) { @@ -226,13 +172,8 @@ export const validateNotificationTypes = ( seen.set(entry.definition.id, entry.pluginId); } - - return [...entries]; }; -export const notificationTypeKey = (pluginId: string, type: string): string => - `${pluginId}:${type}`; - export interface NotificationRegistry { get: (type: string) => RegisteredNotificationType | undefined; list: RegisteredNotificationType[]; @@ -241,7 +182,8 @@ export interface NotificationRegistry { export const createNotificationRegistry = ( entries: readonly RegisteredNotificationType[], ): NotificationRegistry => { - const list = validateNotificationTypes(entries); + validateNotificationTypes(entries); + const list = [...entries]; const byId = new Map(list.map(entry => [entry.definition.id, entry])); return { @@ -250,11 +192,6 @@ export const createNotificationRegistry = ( }; }; -/** - * Parses stored event data with the type's current schema, upgrading it first - * when it was written by an older version. `null` means the data can no longer - * be shown - the inbox renders an "unavailable" placeholder instead. - */ export const parseStoredNotificationData = ( definition: AnyNotificationTypeDefinition, data: unknown, diff --git a/packages/vitnode/src/api/lib/notifications/safe.ts b/packages/vitnode/src/api/lib/notifications/safe.ts index 968cd4439..5b2cdb711 100644 --- a/packages/vitnode/src/api/lib/notifications/safe.ts +++ b/packages/vitnode/src/api/lib/notifications/safe.ts @@ -2,11 +2,6 @@ const MAX_TARGET_LENGTH = 2048; const MAX_TEXT_LENGTH = 500; const MAX_ERROR_LENGTH = 500; -/** - * Accepts only a path on this site. Rejects absolute URLs, protocol-relative - * `//host`, backslash tricks browsers normalise into `//`, and control - * characters - so a notification can never send someone off-site. - */ export const safeNotificationTarget = (target: unknown): null | string => { if (typeof target !== "string") return null; if (target.length === 0 || target.length > MAX_TARGET_LENGTH) return null; @@ -26,7 +21,6 @@ export const safeNotificationTarget = (target: unknown): null | string => { } }; -/** Plain, single-paragraph text: no markup survives, length is bounded. */ export const plainNotificationText = (value: unknown): string => { if (typeof value !== "string") return ""; @@ -40,10 +34,6 @@ export const plainNotificationText = (value: unknown): string => { ); }; -/** - * Provider errors can echo the recipient address, API keys in URLs or whole - * SMTP transcripts. Diagnostics only need the gist. - */ export const sanitizeDeliveryError = (error: unknown): string => { const raw = error instanceof Error diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index d9b27f2cb..8231705f8 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -30,7 +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 { createNotificationRegistry } from "./notifications/registry"; +import { validateNotificationTypes } from "./notifications/registry"; import { applyModuleTags } from "./openapi-tags"; export type { ApiPluginContract }; @@ -84,7 +84,6 @@ export function buildApiPlugin< messages?: LocaleMessagesMap; modules?: Modules; navigation?: NavigationPresetDeclaration[]; - /** Notification types this plugin publishes; see `buildNotificationType`. */ notificationTypes?: AnyNotificationTypeDefinition[]; permissionStaff?: PermissionStaffConfig; pluginId: P; @@ -115,7 +114,9 @@ export function buildApiPlugin< const cronJobs: BuildPluginApiReturn["cronJobs"] = []; const events: BuildPluginApiReturn["events"] = []; const indexers: SearchIndexer[] = [...(searchIndexers ?? [])]; - const types: AnyNotificationTypeDefinition[] = [...(notificationTypes ?? [])]; + const pluginNotificationTypes: AnyNotificationTypeDefinition[] = [ + ...(notificationTypes ?? []), + ]; const openApiTags: string[] = []; const queueTasks: BuildPluginApiReturn["queueTasks"] = []; const webSockets: BuildPluginApiReturn["webSockets"] = []; @@ -128,7 +129,9 @@ export function buildApiPlugin< contentTypes.push(...collectContentTypes(handler)); publicContentTypes.push(...collectPublicContentTypes(handler)); indexers.push(...collectSearchIndexers(handler)); - types.push(...collectModuleTree(handler, m => m.notificationTypes)); + pluginNotificationTypes.push( + ...collectModuleTree(handler, m => m.notificationTypes), + ); handler.cronJobs?.forEach(cron => { cronJobs.push({ ...cron, module: handler.name }); @@ -153,10 +156,8 @@ export function buildApiPlugin< validateSearchIndexers(indexers.map(indexer => ({ ...indexer, pluginId }))); - // Caught here too, so a plugin registering a type twice is named as the one - // that made the mistake; the global middleware re-checks across plugins. - createNotificationRegistry( - types.map(definition => ({ definition, pluginId })), + validateNotificationTypes( + pluginNotificationTypes.map(definition => ({ definition, pluginId })), ); const publishing = new Map<string, AnyContentTypeDefinition>(); @@ -184,7 +185,7 @@ export function buildApiPlugin< cronJobs, events, queueTasks, - notificationTypes: types, + notificationTypes: pluginNotificationTypes, searchIndexers: indexers, webSockets, // Every content type contributes can_view/can_create/can_edit/can_delete @@ -234,7 +235,6 @@ function collectSearchIndexers(module: BaseBuildModuleReturn): SearchIndexer[] { ]; } -/** Collects one list from a module and every module nested inside it. */ function collectModuleTree<T>( module: BaseBuildModuleReturn, pick: (module: BaseBuildModuleReturn) => T[] | undefined, diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index bc56bc9b8..cdb9b1bb7 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -155,7 +155,6 @@ export interface EnvVariablesVitNode { }; /** Every prebuilt main-menu item the installed plugins offer. */ navigation: NavigationPreset[]; - /** Every notification type and subject the installed plugins register. */ notifications?: NotificationRegistry; notificationWorkers?: NotificationWorkerSettings; permissionStaff: PermissionStaffCatalogEntry[]; @@ -381,7 +380,6 @@ export const globalMiddleware = ({ const navigationMetadata: NavigationPreset[] = collectNavigationPresets(plugins); - // Validated across *all* plugins: `buildApiPlugin` only sees its own types. const notificationsMetadata = createNotificationRegistry( plugins.flatMap(plugin => (plugin.notificationTypes ?? []).map(definition => ({ @@ -503,8 +501,6 @@ export const globalMiddleware = ({ await next(); - // By now the handler returned, so any transaction it published inside has - // committed or rolled back. Delivery starts without delaying the response. c.get("notifications").flushAfterResponse(); }; }; diff --git a/packages/vitnode/src/api/models/email.ts b/packages/vitnode/src/api/models/email.ts index d35403f0a..caa948a1b 100644 --- a/packages/vitnode/src/api/models/email.ts +++ b/packages/vitnode/src/api/models/email.ts @@ -29,26 +29,17 @@ interface EmailModelSendArgsWithEmail { export interface EmailApiPlugin { sendEmail: (args: { html: string; - /** - * Stable per logical email. Adapters whose provider deduplicates on a key - * should forward it, so a retry after an unconfirmed send is not delivered - * twice. Optional - providers without the feature can ignore it. - */ idempotencyKey?: string; metadata: ContextVariableMap["core"]["metadata"]; replyTo?: string; subject: string; text: string; to: string; - // `void` on purpose: adapters written before this returned nothing, and - // must keep compiling. // eslint-disable-next-line @typescript-eslint/no-invalid-void-type }) => Promise<EmailSendResult | void>; } -/** What a provider reports back, when it reports anything. */ export interface EmailSendResult { - /** The provider's id for the accepted message, for delivery diagnostics. */ id?: string; } @@ -152,7 +143,7 @@ export class EmailModel { ): Promise<EmailSendResult> { const provider = this.requireProvider(); - const result = await provider.sendEmail({ + const sendResult = await provider.sendEmail({ html: email.html, idempotencyKey, to: email.to, @@ -162,7 +153,7 @@ export class EmailModel { text: email.text, }); - return result ?? {}; + return sendResult ?? {}; } async send(args: EmailModelSendArgs): Promise<void> { diff --git a/packages/vitnode/src/api/models/notifications/admin.ts b/packages/vitnode/src/api/models/notifications/admin.ts index 0c2feae1b..5e3de8cf6 100644 --- a/packages/vitnode/src/api/models/notifications/admin.ts +++ b/packages/vitnode/src/api/models/notifications/admin.ts @@ -21,7 +21,7 @@ import { core_queue } from "@/database/queue"; import type { NotificationsContext } from "./shared"; import { dispatchEmailDrain } from "./email-queue"; -import { reconcileNotificationCounts } from "./inbox"; +import { reconcileNotificationCounts, UNREAD_SQL } from "./inbox"; import { translateOr } from "./preferences"; import { createTranslatorCache } from "./render"; import { @@ -33,23 +33,21 @@ import { typeSettingsKey, } from "./shared"; -const countBy = <K extends string>(rows: { count: number; key: K }[]) => - Object.fromEntries(rows.map(row => [row.key, row.count])) as Partial< - Record<K, number> - >; +const countBy = <K extends string>(rows: { count: number; key: K }[]) => { + const counts: Partial<Record<K, number>> = {}; + for (const row of rows) counts[row.key] = row.count; + + return counts; +}; -/** - * Everything the AdminCP overview shows. Only aggregates and type metadata - - * no notification content and no recipient data. - */ export const getNotificationsOverview = async (c: NotificationsContext) => { const db = c.get("db"); const core = c.get("core"); - const settings = await loadNotificationSettings(db); const emailConfigured = isEmailConfigured(c); - const { t } = await createTranslatorCache(c)(c.get("admin")?.user.language); const [ + settings, + { t }, queueRows, deliveryRows, eventRows, @@ -58,6 +56,8 @@ export const getNotificationsOverview = async (c: NotificationsContext) => { [inbox], [members], ] = await Promise.all([ + loadNotificationSettings(db), + createTranslatorCache(c)(c.get("admin")?.user.language), db .select({ count: count(), @@ -91,10 +91,9 @@ export const getNotificationsOverview = async (c: NotificationsContext) => { db .select({ items: count(), - unread: - sql<number>`count(*) filter (where ${core_notifications.archivedAt} IS NULL AND ${core_notifications.readSeq} < ${core_notifications.activitySeq})`.mapWith( - Number, - ), + unread: sql<number>`count(*) filter (where ${UNREAD_SQL})`.mapWith( + Number, + ), }) .from(core_notifications), db @@ -105,10 +104,10 @@ export const getNotificationsOverview = async (c: NotificationsContext) => { return { counts: { - customizedMembers: members?.customized ?? 0, - inboxItems: inbox?.items ?? 0, + customizedMembers: members.customized, + inboxItems: inbox.items, queuedEmails: countBy(deliveryRows).pending ?? 0, - unreadItems: inbox?.unread ?? 0, + unreadItems: inbox.unread, }, email: { adapterConfigured: !!core.email?.adapter, @@ -158,7 +157,7 @@ export const getNotificationsOverview = async (c: NotificationsContext) => { }; }; -const upsertSetting = async ( +export const upsertNotificationSetting = async ( c: NotificationsContext, key: string, value: Record<string, unknown>, @@ -202,17 +201,12 @@ export const updateNotificationTypePolicy = async ( if (patch[key] !== undefined) Object.assign(merged, { [key]: patch[key] }); } - await upsertSetting(c, typeSettingsKey(typeId), { ...merged }); + await upsertNotificationSetting(c, typeSettingsKey(typeId), { ...merged }); await c.get("events").emit("notifications.type.updated", { typeId }); return merged; }; -/** - * Delivery records for diagnosis. Deliberately leaves out addresses, subjects - * and content: the type, mode, attempts and a sanitized error are what an - * admin needs to find the fault, and the recipient stays a user id. - */ export const listNotificationDeliveries = async ( c: NotificationsContext, { @@ -264,11 +258,6 @@ export const listNotificationDeliveries = async ( }; }; -/** - * Puts a failed delivery back in line with a fresh set of attempts. It keeps - * its row and idempotency key, so the provider sees the same key and the - * same receipts are rendered - a retry can never fan out into a second email. - */ export const retryNotificationDelivery = async ( c: NotificationsContext, deliveryId: number, @@ -299,7 +288,6 @@ export const retryNotificationDelivery = async ( await dispatchEmailDrain(c); }; -/** Queues a test notification email to the signed-in admin - nobody else. */ export const queueNotificationTestEmail = async ( c: NotificationsContext, adminUserId: number, @@ -332,8 +320,7 @@ export const queueNotificationTestEmail = async ( const MISMATCH_SCAN_LIMIT = 500; -/** Users whose stored unread count disagrees with their inbox. */ -export const findNotificationCountMismatches = async ( +const findNotificationCountMismatches = async ( c: NotificationsContext, userId?: number, ) => { @@ -344,9 +331,7 @@ export const findNotificationCountMismatches = async ( userId: core_notifications.userId, }) .from(core_notifications) - .where( - sql`${core_notifications.archivedAt} IS NULL AND ${core_notifications.readSeq} < ${core_notifications.activitySeq}`, - ) + .where(UNREAD_SQL) .groupBy(core_notifications.userId) .as("actual"); diff --git a/packages/vitnode/src/api/models/notifications/danger.ts b/packages/vitnode/src/api/models/notifications/danger.ts index 92ebf0659..8007b0ede 100644 --- a/packages/vitnode/src/api/models/notifications/danger.ts +++ b/packages/vitnode/src/api/models/notifications/danger.ts @@ -1,19 +1,20 @@ -import { and, eq, inArray, isNull, notExists, sql } from "drizzle-orm"; +import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { core_notification_deliveries, core_notification_events, core_notification_receipts, - core_notification_settings, 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, @@ -23,19 +24,13 @@ import { } from "./shared"; const USERS_PER_BATCH = 200; -const DELETE_BATCH = 1000; const setPaused = async (c: NotificationsContext, paused: boolean) => { const { global } = await loadNotificationSettings(c.get("db")); - const value = { ...global, paused }; - await c - .get("db") - .insert(core_notification_settings) - .values({ key: GLOBAL_SETTINGS_KEY, updatedAt: new Date(), value }) - .onConflictDoUpdate({ - set: { updatedAt: new Date(), value }, - target: core_notification_settings.key, - }); + await upsertNotificationSetting(c, GLOBAL_SETTINGS_KEY, { + ...global, + paused, + }); }; export const pauseNotifications = async (c: NotificationsContext) => { @@ -54,14 +49,16 @@ export const resumeNotifications = async ( .from(core_notification_events) .where(inArray(core_notification_events.status, ["pending", "processing"])); - for (const { id } of waiting) { - await c.get("queue").dispatch({ - name: QUEUE_NOTIFICATIONS_FANOUT, - payload: { eventId: id }, - pluginId: NOTIFICATIONS_PLUGIN_ID, - priority: 10, - }); - } + 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") @@ -90,12 +87,13 @@ export const cancelQueuedNotificationEmails = async ( .where( and( eq(core_notification_receipts.emailPending, true), - rows.length === 0 - ? isNull(core_notification_receipts.emailDeliveryId) - : sql`(${core_notification_receipts.emailDeliveryId} IS NULL OR ${inArray( - core_notification_receipts.emailDeliveryId, - rows.map(row => row.id), - )})`, + or( + isNull(core_notification_receipts.emailDeliveryId), + inArray( + core_notification_receipts.emailDeliveryId, + rows.map(row => row.id), + ), + ), ), ); @@ -125,7 +123,7 @@ export const markEverythingRead = async ( if (targets.length === 0) break; const userIds = targets.map(row => row.userId); - const result = await c.get("db").transaction(async tx => { + const batch = await c.get("db").transaction(async tx => { await lockUserStates(tx, userIds); const marked = await tx .update(core_notifications) @@ -146,10 +144,10 @@ export const markEverythingRead = async ( }; }); - sendNotificationStates(c, result.updates); - items += result.count; - members += result.updates.length; - if (result.count === 0) break; + 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 }); @@ -160,45 +158,8 @@ export const markEverythingRead = async ( export const deleteAllNotifications = async ( c: NotificationsContext, ): Promise<{ events: number; items: number }> => { - const db = c.get("db"); const items = await removeNotificationItems(c, { where: sql`true` }); - - 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( - 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; - } + const events = await deleteUnreferencedEvents(c.get("db")); await c.get("events").emit("notifications.deleted_all", { items }); diff --git a/packages/vitnode/src/api/models/notifications/email-queue.ts b/packages/vitnode/src/api/models/notifications/email-queue.ts index 481b4471f..89a3bde25 100644 --- a/packages/vitnode/src/api/models/notifications/email-queue.ts +++ b/packages/vitnode/src/api/models/notifications/email-queue.ts @@ -6,11 +6,6 @@ import type { NotificationsContext, NotificationsDb } from "./shared"; import { NOTIFICATIONS_PLUGIN_ID, QUEUE_NOTIFICATIONS_EMAIL } from "./shared"; -/** - * Makes sure one email drain is queued. Drains are idempotent - each claims - * deliveries with `SKIP LOCKED` - so a second one racing in is harmless; this - * only keeps a burst from queueing hundreds of them. - */ export const dispatchEmailDrain = async ( c: NotificationsContext, tx?: NotificationsDb, diff --git a/packages/vitnode/src/api/models/notifications/email.integration.test.ts b/packages/vitnode/src/api/models/notifications/email.integration.test.ts index 799d8c065..c0a7478c3 100644 --- a/packages/vitnode/src/api/models/notifications/email.integration.test.ts +++ b/packages/vitnode/src/api/models/notifications/email.integration.test.ts @@ -124,7 +124,6 @@ describePostgres("notification email", () => { expect(failed.availableAt.getTime()).toBeGreaterThan(Date.now()); expect(sentTo(user)).toHaveLength(0); - // Not due yet: a drain right now sends nothing. await drainNotificationEmails(h.c); expect(sentTo(user)).toHaveLength(0); @@ -177,8 +176,6 @@ describePostgres("notification email", () => { const readOne = await publish(newsType, user, "Already seen"); await h.drainQueue(); - // Europe moved its clocks forward on 29 March 2026: 08:00 in Warsaw was - // 07:00 UTC on the 28th and 06:00 UTC on the 29th. const setCreated = async (eventId: number, at: string) => await h.c .get("db") @@ -204,6 +201,8 @@ describePostgres("notification email", () => { 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 }); @@ -216,8 +215,8 @@ describePostgres("notification email", () => { expect(digests[0]).toMatchObject({ idempotencyKey: `daily:${user}:2026-03-29`, itemCount: 1, - periodEnd: new Date("2026-03-29T06:00:00Z"), - periodStart: new Date("2026-03-28T07:00:00Z"), + periodEnd: warsawEightAmAfterDstChange, + periodStart: warsawEightAmBeforeDstChange, status: "sent", }); expect( @@ -226,7 +225,6 @@ describePostgres("notification email", () => { ?.entries.map(entry => entry.title), ).toEqual(["Saturday news"]); - // The next period picks up only what the first did not claim. const secondRun = new Date("2026-03-30T07:00:00Z"); await planNotificationDigests(h.c, { now: secondRun }); await drainNotificationEmails(h.c, { now: secondRun }); @@ -237,7 +235,6 @@ describePostgres("notification email", () => { ?.entries.map(entry => entry.title), ).toEqual(["Sunday news"]); - // Nothing is left to send, and running again sends nothing. const sent = sentTo(user).length; await planNotificationDigests(h.c, { now: new Date("2026-03-31T07:00:00Z"), @@ -272,7 +269,6 @@ describePostgres("notification email", () => { await drainNotificationEmails(h.c, { now }); h.setEmailFailure(null); - // A new event after the claim must not slip into the retried digest. await publish(newsType, user, "Three"); await h.drainQueue(); await drainNotificationEmails(h.c, { diff --git a/packages/vitnode/src/api/models/notifications/email.ts b/packages/vitnode/src/api/models/notifications/email.ts index 0eeaeab35..f4638ca95 100644 --- a/packages/vitnode/src/api/models/notifications/email.ts +++ b/packages/vitnode/src/api/models/notifications/email.ts @@ -62,7 +62,9 @@ const absoluteUrl = (target: null | string): null | string => const preferencesUrl = () => new URL("/settings/notifications", CONFIG.web).href; -/** The email mode the user would get for this event right now. */ +const providerIdempotencyKey = (delivery: DeliveryRow) => + `vitnode-notification-${delivery.idempotencyKey}`; + const currentEmailMode = ({ c, event, @@ -93,11 +95,10 @@ interface ClaimedReceipt { seq: null | number; } -/** Drops receipts from email for good, or hands them back to the planner. */ const settleReceipts = async ( c: NotificationsContext, userId: number, - { dropped, released }: { dropped: number[]; released: number[] }, + { dropped, replan }: { dropped: number[]; replan: number[] }, ) => { const db = c.get("db"); if (dropped.length > 0) { @@ -111,20 +112,20 @@ const settleReceipts = async ( ), ); } - if (released.length > 0) { + 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, released), + inArray(core_notification_receipts.eventId, replan), ), ); } }; -const isAlreadyRead = async ( +const loadEventIdsHandledByUser = async ( c: NotificationsContext, receipts: ClaimedReceipt[], ): Promise<Set<number>> => { @@ -133,7 +134,7 @@ const isAlreadyRead = async ( .filter(id => id !== null); if (ids.length === 0) return new Set(); - const items = await c + const notifications = await c .get("db") .select({ archivedAt: core_notifications.archivedAt, @@ -142,7 +143,9 @@ const isAlreadyRead = async ( }) .from(core_notifications) .where(inArray(core_notifications.id, ids)); - const byId = new Map(items.map(item => [item.id, item])); + const notificationById = new Map( + notifications.map(notification => [notification.id, notification]), + ); return new Set( receipts @@ -150,22 +153,19 @@ const isAlreadyRead = async ( if (receipt.notificationId === null || receipt.seq === null) { return false; } - const item = byId.get(receipt.notificationId); - - // Read past this event, archived, or removed: the user dealt with it. - if (!item) return true; + const notification = notificationById.get(receipt.notificationId); + const removed = !notification; - return item.archivedAt !== null || item.readSeq >= receipt.seq; + return ( + removed || + notification.archivedAt !== null || + notification.readSeq >= receipt.seq + ); }) .map(receipt => receipt.eventId), ); }; -/** - * Re-checks every claimed receipt against current preferences, mutes, access - * and read state, then renders what is left. Nothing is decided from the - * state at fan-out time - a delayed email reflects the user's settings now. - */ const deliverNotificationEmail = async ( c: NotificationsContext, delivery: DeliveryRow, @@ -221,7 +221,7 @@ const deliverNotificationEmail = async ( to: user.email, }); const sent = await emailModel.deliver(built, { - idempotencyKey: `vitnode-notification-${delivery.idempotencyKey}`, + idempotencyKey: providerIdempotencyKey(delivery), }); return { providerMessageId: sent.id, status: "sent" }; @@ -253,10 +253,10 @@ const deliverNotificationEmail = async ( if (claimed.length === 0) return { reason: "empty", status: "skipped" }; - const read = await isAlreadyRead(c, claimed); + const handledByUser = await loadEventIdsHandledByUser(c, claimed); const preferences = user.preferences ?? {}; const dropped: number[] = []; - const released: number[] = []; + const replan: number[] = []; const kept: { event: NotificationEventRow; receipt: ClaimedReceipt }[] = []; for (const receipt of claimed) { @@ -267,10 +267,10 @@ const deliverNotificationEmail = async ( settings, }); - if (mode !== delivery.mode) { - // The user switched modes since this was queued; the planner routes it. - (mode === "none" ? dropped : released).push(receipt.eventId); - } else if (read.has(receipt.eventId)) { + 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 }); @@ -322,7 +322,7 @@ const deliverNotificationEmail = async ( }); } - await settleReceipts(c, user.id, { dropped, released }); + await settleReceipts(c, user.id, { dropped, replan }); if (entries.length === 0) return { reason: "empty", status: "skipped" }; const first = entries[0]; @@ -355,10 +355,8 @@ const deliverNotificationEmail = async ( to: user.email, }); - // Same key on every attempt: a provider that deduplicates drops the second - // send if the first was accepted but its answer never reached us. const sent = await emailModel.deliver(built, { - idempotencyKey: `vitnode-notification-${delivery.idempotencyKey}`, + idempotencyKey: providerIdempotencyKey(delivery), }); await db @@ -395,12 +393,6 @@ const runWithConcurrency = async <T>( ); }; -/** - * Sends up to `emailBatchSize` due deliveries, `emailConcurrency` at a time. - * In-app delivery never waits on this: email runs in its own queue task, so a - * slow or failing provider only delays email. - */ - export const drainNotificationEmails = async ( c: NotificationsContext, { now = new Date() }: { now?: Date } = {}, @@ -488,7 +480,7 @@ export const drainNotificationEmails = async ( if (exhausted) stats.failed += 1; await c .get("log") - ?.warn( + .warn( `[Notifications] Email delivery ${delivery.id} failed (attempt ${delivery.attempts}/${delivery.maxAttempts}): ${lastError}`, ); } @@ -522,13 +514,6 @@ export const drainNotificationEmails = async ( return stats; }; -/** - * A worker that died mid-send leaves a delivery in `sending`. After a grace - * period it goes back to `pending`. The provider may already have accepted the - * message - with an idempotency-capable provider the retry is dropped, with - * plain SMTP it can arrive twice. That window is the one unavoidable - * uncertainty; nothing here claims exactly-once email. - */ export const reclaimStaleDeliveries = async ( c: NotificationsContext, now = new Date(), @@ -560,13 +545,6 @@ interface PlannedDelivery { userId: number; } -/** - * Turns pending email receipts into deliveries: one digest per user and - * period, or an immediate email for receipts whose user switched to immediate. - * A digest delivery's key is the user, mode and local date of its period, so a - * period is claimed once however often this runs; receipts it claimed are - * never offered to another digest. - */ export const planNotificationDigests = async ( c: NotificationsContext, { now = new Date() }: { now?: Date } = {}, @@ -669,8 +647,8 @@ export const planNotificationDigests = async ( timeZone: profile.timeZone ?? profile.languageTimeZone ?? "UTC", weekday: NOTIFICATION_DIGEST_SCHEDULE.weekday, }); - // Not due yet: it belongs to the period still running. - if (receipt.createdAt >= period.end) continue; + const belongsToRunningPeriod = receipt.createdAt >= period.end; + if (belongsToRunningPeriod) continue; const key = `${mode}:${receipt.userId}:${period.key}`; const plan = plans.get(key) ?? { @@ -699,13 +677,13 @@ export const planNotificationDigests = async ( `); } - const list = [...plans.values()]; - if (list.length === 0) return 0; + const planned = [...plans.values()]; + if (planned.length === 0) return 0; const inserted = await tx .insert(core_notification_deliveries) .values( - list.map(plan => ({ + planned.map(plan => ({ availableAt: now, channel: "email" as const, createdAt: now, @@ -724,18 +702,21 @@ export const planNotificationDigests = async ( idempotencyKey: core_notification_deliveries.idempotencyKey, }); - const claims = inserted.flatMap(row => { - const plan = plans.get(row.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 => [ + return plan.eventIds.map(eventId => [ sql`${eventId}::bigint`, - sql`${plan?.userId ?? 0}::integer`, - sql`${row.id}::bigint`, + sql`${plan.userId}::integer`, + sql`${deliveryId}::bigint`, ]); }); if (claims.length > 0) { - // `IS NULL` guards against a concurrent planner having claimed first. await tx.execute(sql` UPDATE ${core_notification_receipts} AS r SET "emailDeliveryId" = v.did FROM (VALUES ${valuesList(claims)}) AS v(eid, uid, did) @@ -744,18 +725,16 @@ export const planNotificationDigests = async ( `); } - // An immediate delivery that already existed means this event already - // had its one immediate email; it is not sent again. - const conflicted = list.filter( + const immediateAlreadyDelivered = planned.filter( plan => plan.mode === "immediate" && - !inserted.some(row => row.idempotencyKey === plan.idempotencyKey), + !insertedIdByKey.has(plan.idempotencyKey), ); - if (conflicted.length > 0) { + if (immediateAlreadyDelivered.length > 0) { await tx.execute(sql` UPDATE ${core_notification_receipts} AS r SET "emailPending" = false FROM (VALUES ${valuesList( - conflicted.map(plan => [ + immediateAlreadyDelivered.map(plan => [ sql`${plan.eventIds[0]}::bigint`, sql`${plan.userId}::integer`, ]), diff --git a/packages/vitnode/src/api/models/notifications/fanout.integration.test.ts b/packages/vitnode/src/api/models/notifications/fanout.integration.test.ts index f4ed4f280..35dda5769 100644 --- a/packages/vitnode/src/api/models/notifications/fanout.integration.test.ts +++ b/packages/vitnode/src/api/models/notifications/fanout.integration.test.ts @@ -140,7 +140,6 @@ describePostgres("notification fan-out", () => { expect(again).toEqual({ duplicate: true, eventId: first.eventId }); await h.drainQueue(); - // A retry of the whole event after it completed changes nothing. await processNotificationEvent(h.c, first.eventId); expect(await unreadOf(a)).toBe(1); @@ -170,8 +169,6 @@ describePostgres("notification fan-out", () => { }); await h.drainQueue(); - // Simulate a worker that committed the batch but died before recording - // completion: rewind the event's cursor and process it again. await h.c .get("db") .update(core_notification_events) 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 index 52cef15e3..c34895472 100644 --- a/packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts +++ b/packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts @@ -40,11 +40,6 @@ const announcementType = buildNotificationType({ present: ({ data }) => ({ title: data.title }), }); -/** - * The 1,000-recipient case, with duplicates in the list and one recipient who - * turned the type off. Timings are printed for the record, never asserted - - * they depend on the machine. - */ describePostgres("notification fan-out with 1,000 recipients", () => { let h: NotificationsHarness; let users: number[] = []; @@ -84,12 +79,11 @@ describePostgres("notification fan-out with 1,000 recipients", () => { }); const publishMs = performance.now() - started; - // Two workers race on the same event; the event row lock serializes them. - const fork = h.forkContext(); + const racingWorker = h.forkContext(); const fanoutStarted = performance.now(); await Promise.all([ processNotificationEvent(h.c, eventId), - processNotificationEvent(fork, eventId), + processNotificationEvent(racingWorker, eventId), ]); const fanoutMs = performance.now() - fanoutStarted; const fanoutQueries = h.queryCount() - queriesBefore; @@ -118,8 +112,6 @@ describePostgres("notification fan-out with 1,000 recipients", () => { status: "completed", }); - // Repeated processing: rewind the cursors as a crashed worker would leave - // them, run it again twice, and nothing may change. await h.c .get("db") .update(core_notification_events) @@ -148,10 +140,8 @@ describePostgres("notification fan-out with 1,000 recipients", () => { .from(core_notification_deliveries); expect(deliveries[0]?.count).toBe(expected); - // Bounded work: the access check ran once per batch, not per recipient, - // and the whole fan-out took a fixed number of queries per batch. - const batches = Math.ceil(RECIPIENTS / BATCH); - expect(accessCalls).toBeLessThanOrEqual(batches * 2 + 4); + const batchesPerWorker = Math.ceil(RECIPIENTS / BATCH); + expect(accessCalls).toBeLessThanOrEqual(batchesPerWorker * 2 + 4); expect(fanoutQueries).toBeLessThan(RECIPIENTS); // eslint-disable-next-line no-console diff --git a/packages/vitnode/src/api/models/notifications/fanout.ts b/packages/vitnode/src/api/models/notifications/fanout.ts index 38b54a643..7ad93b5a6 100644 --- a/packages/vitnode/src/api/models/notifications/fanout.ts +++ b/packages/vitnode/src/api/models/notifications/fanout.ts @@ -16,6 +16,7 @@ import { } from "@/database/notifications"; import { core_users } from "@/database/users"; +import type { NotificationEventRow } from "./render"; import type { NotificationsContext, NotificationsDb, @@ -23,6 +24,7 @@ import type { } from "./shared"; import { dispatchEmailDrain } from "./email-queue"; +import { checkNotificationAccess } from "./render"; import { getNotificationRegistry, getNotificationWorkers, @@ -31,31 +33,24 @@ import { sendNotificationStates, } from "./shared"; -type EventRow = typeof core_notification_events.$inferSelect; type StateUpdate = NotificationStateMessage & { userId: number }; -/** `(a, b), (c, d)` - rows for a `VALUES` list. */ export const valuesList = (rows: SQL[][]): SQL => sql.join( rows.map(row => sql`(${sql.join(row, sql`, `)})`), sql`, `, ); -/** - * Creates the user-state rows a batch needs and locks them, always in user id - * order. Every inbox write takes these locks first, so two writers touching - * the same users can never deadlock, and a user's inbox changes serialize. - */ export const lockUserStates = async ( tx: NotificationsDb, userIds: number[], ) => { - const sorted = [...new Set(userIds)].sort((a, b) => a - b); - if (sorted.length === 0) return []; + const userIdsInLockOrder = [...new Set(userIds)].sort((a, b) => a - b); + if (userIdsInLockOrder.length === 0) return []; await tx .insert(core_notification_user_state) - .values(sorted.map(userId => ({ userId }))) + .values(userIdsInLockOrder.map(userId => ({ userId }))) .onConflictDoNothing(); return await tx @@ -64,15 +59,11 @@ export const lockUserStates = async ( userId: core_notification_user_state.userId, }) .from(core_notification_user_state) - .where(inArray(core_notification_user_state.userId, sorted)) + .where(inArray(core_notification_user_state.userId, userIdsInLockOrder)) .orderBy(asc(core_notification_user_state.userId)) .for("update"); }; -/** - * Applies unread-count deltas to rows already locked by `lockUserStates`, - * bumping each user's revision exactly once. - */ export const applyUnreadDeltas = async ( tx: NotificationsDb, deltas: Map<number, number>, @@ -120,7 +111,10 @@ interface Candidates { userIds: number[]; } -const nextCandidates = (event: EventRow, batchSize: number): Candidates => { +const nextCandidates = ( + event: NotificationEventRow, + batchSize: number, +): Candidates => { const userIds = event.recipientIds.slice( event.recipientCursor, event.recipientCursor + batchSize, @@ -147,7 +141,6 @@ const failEvent = async (tx: NotificationsDb, id: number, error: string) => { .where(eq(core_notification_events.id, id)); }; -/** Narrows candidates to who may and wants to receive this event. */ const filterRecipients = async ({ c, candidates, @@ -158,7 +151,7 @@ const filterRecipients = async ({ c: NotificationsContext; candidates: number[]; data: unknown; - event: EventRow; + event: NotificationEventRow; registered: RegisteredNotificationType; }) => { const notSelf = candidates.filter( @@ -166,27 +159,23 @@ const filterRecipients = async ({ ); if (notSelf.length === 0) return []; - const db = c.get("db"); - const existing = await db + const existing = await c + .get("db") .select({ id: core_users.id }) .from(core_users) .where(inArray(core_users.id, notSelf)); - let eligible = existing.map(row => row.id); - - const subject = - event.subjectType && event.subjectId - ? { id: event.subjectId, type: event.subjectType } - : null; + const eligible = existing.map(row => row.id); + if (eligible.length === 0) return []; - const { access } = registered.definition; - if (access && eligible.length > 0) { - const allowed = new Set( - await access({ c, data, subject, userIds: [...eligible] }), - ); - eligible = eligible.filter(id => allowed.has(id)); - } + const allowed = await checkNotificationAccess({ + c, + data, + event, + registered, + userIds: eligible, + }); - return eligible; + return eligible.filter(id => allowed.has(id)); }; const processBatch = async ( @@ -273,10 +262,12 @@ const processBatch = async ( .onConflictDoNothing() .returning({ userId: core_notification_receipts.userId }); - // Only users whose receipt is new go further: a retry of a batch that - // already committed finds every receipt in place and changes nothing. - const fresh = inserted.map(row => row.userId).sort((a, b) => a - b); - const inApp = fresh.filter(userId => channels.get(userId)?.inApp); + 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}`; @@ -374,7 +365,7 @@ const processBatch = async ( delivered = inApp.length; } - const immediate = fresh.filter( + const immediate = firstTimeRecipients.filter( userId => channels.get(userId)?.email === "immediate", ); if (immediate.length > 0) { @@ -429,19 +420,13 @@ const processBatch = async ( return { delivered, done: candidates.done, states }; }; -export interface FanoutResult { +interface FanoutResult { batches: number; delivered: number; done: boolean; paused?: boolean; } -/** - * Delivers an event batch by batch, each in its own short transaction that - * also saves the cursor - a crash or retry resumes after the last committed - * batch. Stops after `timeBudgetMs` so one huge audience cannot hold the - * queue worker; the caller re-queues the rest. - */ export const processNotificationEvent = async ( c: NotificationsContext, eventId: number, @@ -450,21 +435,21 @@ export const processNotificationEvent = async ( const db = c.get("db"); const settings = await loadNotificationSettings(db); const deadline = Date.now() + timeBudgetMs; - const result: FanoutResult = { batches: 0, delivered: 0, done: false }; - if (settings.global.paused) return { ...result, done: true, paused: true }; + const progress: FanoutResult = { batches: 0, delivered: 0, done: false }; + if (settings.global.paused) return { ...progress, done: true, paused: true }; - while (!result.done) { + while (!progress.done) { const batch = await db.transaction( async tx => await processBatch(c, tx, eventId, settings), ); sendNotificationStates(c, batch.states); - result.batches += 1; - result.delivered += batch.delivered; - result.done = batch.done; + progress.batches += 1; + progress.delivered += batch.delivered; + progress.done = batch.done; - if (!result.done && Date.now() >= deadline) break; + if (!progress.done && Date.now() >= deadline) break; } - return result; + 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 index 91877ed13..6c6281d94 100644 --- a/packages/vitnode/src/api/models/notifications/inbox.integration.test.ts +++ b/packages/vitnode/src/api/models/notifications/inbox.integration.test.ts @@ -50,8 +50,7 @@ const pingType = buildNotificationType({ present: ({ data }) => ({ title: `Ping ${data.n}` }), }); -/** Turns the access check on and off at runtime, like revoking a permission. */ -let revoked = new Set<number>(); +let revokedUserIds = new Set<number>(); const privateType = buildNotificationType({ id: "test.private", version: 1, @@ -59,7 +58,7 @@ const privateType = buildNotificationType({ category: "content", label: "test.private.label", defaults: { email: "none", inApp: true }, - access: ({ userIds }) => userIds.filter(id => !revoked.has(id)), + access: ({ userIds }) => userIds.filter(id => !revokedUserIds.has(id)), present: ({ data }) => ({ title: `Secret: ${data.secret}`, target: "/secret", @@ -127,7 +126,6 @@ describePostgres("notification inbox state", () => { }); expect(read.unread).toBe(0); - // Repeating the read never decrements again. const again = await markNotificationRead(h.c, { notificationId: item.id, userId: user, @@ -189,7 +187,6 @@ describePostgres("notification inbox state", () => { await reply(owner, a); const [rendered] = await itemsOf(owner); - // Another reply joins the group after the user saw it, before the click. await reply(owner, b); const after = await markNotificationRead(h.c, { notificationId: rendered.id, @@ -203,7 +200,6 @@ describePostgres("notification inbox state", () => { expect(item.readSeq).toBe(1); expect(item.activitySeq).toBe(2); - // Read with the new boundary: now it is read, and new activity re-opens it once. await markNotificationRead(h.c, { notificationId: item.id, throughSeq: item.activitySeq, @@ -232,7 +228,6 @@ describePostgres("notification inbox state", () => { it("starts a new item once the grouping window has passed", async () => { const [owner, actor] = await h.createUsers(2); await reply(owner, actor); - // Age the first group by two windows. await h.c .get("db") .update(core_notifications) @@ -249,32 +244,31 @@ describePostgres("notification inbox state", () => { await ping(user); await ping(user); - // Hold the user's lock in another connection as if a fan-out were mid-way: - // mark-all must wait for it and then include what it committed. - const other = h.forkContext(); - let release: () => void = () => undefined; - const held = new Promise<void>(resolve => { - release = resolve; - }); - const blocker = other.get("db").transaction(async tx => { - await tx - .select() - .from(core_notification_user_state) - .where(eq(core_notification_user_state.userId, user)) - .for("update"); - await held; + const fanoutInProgress = h.forkContext(); + let releaseUserLock: () => void = () => undefined; + const userLockHeld = new Promise<void>(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)); - release(); - await blocker; + releaseUserLock(); + await fanoutHoldingUserLock; const result = await markAll; expect(result.marked).toBe(2); expect(result.unread).toBe(0); - // Delivered after the boundary: stays unread. await ping(user); expect((await state(user)).unread).toBe(1); }); @@ -297,7 +291,6 @@ describePostgres("notification inbox state", () => { ), ); - // Deliver concurrently from three connections while reading. await Promise.all( published.map(async ({ eventId }, index) => { await processNotificationEvent(forks[index % forks.length], eventId); @@ -353,11 +346,10 @@ describePostgres("notification inbox state", () => { title: "Secret: plans", }); - revoked = new Set([alice]); + revokedUserIds = new Set([alice]); const hidden = await listNotifications(h.c, { limit: 20, userId: alice }); - revoked = new Set(); + revokedUserIds = new Set(); - // Still listed - so it can be cleared - but with nothing private in it. expect(hidden.items[0]).toMatchObject({ actors: [], available: false, diff --git a/packages/vitnode/src/api/models/notifications/inbox.ts b/packages/vitnode/src/api/models/notifications/inbox.ts index 2311dfcf1..17e25c0f2 100644 --- a/packages/vitnode/src/api/models/notifications/inbox.ts +++ b/packages/vitnode/src/api/models/notifications/inbox.ts @@ -1,5 +1,6 @@ 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, @@ -31,12 +32,9 @@ 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 { - /** Pass back to `markRead` - the read boundary of what was shown. */ activitySeq: number; - /** Distinct actors across every grouped event. */ actorCount: number; actors: NotificationActor[]; - /** `false` when the content is gone, access was lost or the plugin is uninstalled. */ available: boolean; body: null | string; category: string; @@ -58,34 +56,26 @@ export interface NotificationListPage { nextCursor: null | string; } -interface Cursor { - id: number; - t: number; -} +const cursorSchema = z.object({ id: z.number().int(), t: z.number().int() }); -const encodeCursor = (cursor: Cursor): string => +const encodeCursor = (cursor: z.infer<typeof cursorSchema>): string => Buffer.from(JSON.stringify(cursor)).toString("base64url"); -const decodeCursor = (raw: string): Cursor => { +const parseCursorJson = (raw: string): unknown => { try { - const parsed: unknown = JSON.parse( - Buffer.from(raw, "base64url").toString("utf8"), - ); - if ( - typeof parsed === "object" && - parsed !== null && - "id" in parsed && - "t" in parsed && - Number.isSafeInteger(parsed.id) && - Number.isSafeInteger(parsed.t) - ) { - return parsed as Cursor; - } + return JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); } catch { - // fall through + return null; + } +}; + +const decodeCursor = (raw: string) => { + const cursor = cursorSchema.safeParse(parseCursorJson(raw)); + if (!cursor.success) { + throw new HTTPException(400, { message: "Invalid pagination cursor." }); } - throw new HTTPException(400, { message: "Invalid pagination cursor." }); + return { id: cursor.data.id, lastActivityAt: new Date(cursor.data.t) }; }; export const getNotificationState = async ( @@ -104,13 +94,6 @@ export const getNotificationState = async ( return row ?? { revision: 0, unread: 0 }; }; -/** - * One page of a user's inbox, newest activity first. Every item is re-checked - * against its type before anything is returned: an item whose content is gone, - * whose access was revoked or whose plugin is uninstalled comes back as an - * `available: false` placeholder - no title, link or actors - so it can still - * be read or archived instead of silently inflating the count. - */ export const listNotifications = async ( c: NotificationsContext, { @@ -132,7 +115,6 @@ export const listNotifications = async ( }, ): Promise<NotificationListPage> => { const after = cursor ? decodeCursor(cursor) : null; - const afterDate = after ? new Date(after.t) : null; const rows = await c .get("db") @@ -152,11 +134,11 @@ export const listNotifications = async ( unreadOnly ? UNREAD_SQL : undefined, category ? eq(core_notifications.category, category) : undefined, type ? eq(core_notifications.type, type) : undefined, - after && afterDate + after ? or( - lt(core_notifications.lastActivityAt, afterDate), + lt(core_notifications.lastActivityAt, after.lastActivityAt), and( - eq(core_notifications.lastActivityAt, afterDate), + eq(core_notifications.lastActivityAt, after.lastActivityAt), lt(core_notifications.id, after.id), ), ) @@ -261,16 +243,14 @@ export const listNotifications = async ( type ItemRow = typeof core_notifications.$inferSelect; +const USERS_PER_RECONCILE_BATCH = 200; +const USERS_PER_REMOVE_BATCH = 50; + const isCounted = ( item: Pick<ItemRow, "activitySeq" | "archivedAt" | "readSeq">, ) => item.archivedAt === null && item.readSeq < item.activitySeq; -/** - * Runs one item change under the user's state lock and applies the change to - * the canonical count in the same transaction. Returns the new state, which is - * pushed to the user's connections only after commit. - */ -const changeItem = async ( +const changeItemUnderUserLock = async ( c: NotificationsContext, { change, @@ -284,10 +264,9 @@ const changeItem = async ( userId: number; }, ): Promise<NotificationState> => { - const result = await c.get("db").transaction(async tx => { + const { state, update } = await c.get("db").transaction(async tx => { await lockUserStates(tx, [userId]); - // Scoped by user: someone else's id is indistinguishable from a missing one. const [item] = await tx .select() .from(core_notifications) @@ -319,27 +298,19 @@ const changeItem = async ( item.id, ); - return { - state: update ?? (await getNotificationState(tx, userId)), - update, - }; + return { state: update, update }; }); - if (result.update) sendNotificationStates(c, [result.update]); + if (update) sendNotificationStates(c, [update]); - return { revision: result.state.revision, unread: result.state.unread }; + return { revision: state.revision, unread: state.unread }; }; -/** - * Marks an item read up to `throughSeq` - the `activitySeq` the user saw. A - * grouped event that arrived after the item was rendered pushes `activitySeq` - * past that boundary, so the item correctly stays unread. - */ export const markNotificationRead = async ( c: NotificationsContext, args: { notificationId: number; throughSeq?: number; userId: number }, ): Promise<NotificationState> => - await changeItem(c, { + await changeItemUnderUserLock(c, { change: (item, now) => { const boundary = Math.min( args.throughSeq ?? item.activitySeq, @@ -358,7 +329,7 @@ export const markNotificationUnread = async ( c: NotificationsContext, args: { notificationId: number; userId: number }, ): Promise<NotificationState> => - await changeItem(c, { + await changeItemUnderUserLock(c, { change: item => item.readSeq < item.activitySeq ? null @@ -372,27 +343,22 @@ export const archiveNotification = async ( c: NotificationsContext, args: { notificationId: number; userId: number }, ): Promise<NotificationState> => - await changeItem(c, { + await changeItemUnderUserLock(c, { change: (item, now) => (item.archivedAt ? null : { archivedAt: now }), notificationId: args.notificationId, reason: "archived", userId: args.userId, }); -/** - * Marks everything read in one statement, after taking the user's state lock. - * That lock is the boundary: a fan-out that committed before it is included, - * one still running waits for it and then adds its item as unread. - */ export const markAllNotificationsRead = async ( c: NotificationsContext, { category, userId }: { category?: string; userId: number }, ): Promise<NotificationState & { marked: number }> => { - const result = await c.get("db").transaction(async tx => { + const { marked, state, update } = await c.get("db").transaction(async tx => { await lockUserStates(tx, [userId]); const now = new Date(); - const marked = await tx + const markedItems = await tx .update(core_notifications) .set({ readAt: now, readSeq: sql`${core_notifications.activitySeq}` }) .where( @@ -404,7 +370,7 @@ export const markAllNotificationsRead = async ( ) .returning({ id: core_notifications.id }); - if (marked.length === 0) { + if (markedItems.length === 0) { return { marked: 0, state: await getNotificationState(tx, userId), @@ -414,26 +380,18 @@ export const markAllNotificationsRead = async ( const [update] = await applyUnreadDeltas( tx, - new Map([[userId, -marked.length]]), + new Map([[userId, -markedItems.length]]), "read_all", ); - return { marked: marked.length, state: update, update }; + return { marked: markedItems.length, state: update, update }; }); - if (result.update) sendNotificationStates(c, [result.update]); + if (update) sendNotificationStates(c, [update]); - return { - marked: result.marked, - revision: result.state.revision, - unread: result.state.unread, - }; + return { marked, revision: state.revision, unread: state.unread }; }; -/** - * Recounts unread items for each user from scratch and fixes the stored count - * where it drifted. For diagnostics and recovery, not the hot path. - */ export const reconcileNotificationCounts = async ( c: NotificationsContext, userIds: number[], @@ -441,8 +399,12 @@ export const reconcileNotificationCounts = async ( let corrected = 0; const updates: StateUpdate[] = []; - for (let index = 0; index < userIds.length; index += 200) { - const chunk = userIds.slice(index, index + 200); + 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); @@ -490,21 +452,9 @@ export const reconcileNotificationCounts = async ( return { checked: userIds.length, corrected }; }; -/** - * Removes inbox items - when access is revoked, content is deleted or by - * retention - and takes their unread share off each owner's count in the same - * transaction. - */ export const removeNotificationItems = async ( c: NotificationsContext, - { - reason = "removed", - where, - }: { - reason?: NotificationStateMessage["reason"]; - where: ReturnType<typeof and>; - }, - { batchSize = 500 }: { batchSize?: number } = {}, + { where }: { where: ReturnType<typeof and> }, ): Promise<number> => { let removed = 0; @@ -515,11 +465,11 @@ export const removeNotificationItems = async ( .from(core_notifications) .where(where) .groupBy(core_notifications.userId) - .limit(Math.max(1, Math.floor(batchSize / 10))); + .limit(USERS_PER_REMOVE_BATCH); if (targets.length === 0) break; const userIds = targets.map(row => row.userId); - const result = await c.get("db").transaction(async tx => { + const batch = await c.get("db").transaction(async tx => { await lockUserStates(tx, userIds); const deleted = await tx @@ -542,13 +492,13 @@ export const removeNotificationItems = async ( return { count: deleted.length, - updates: await applyUnreadDeltas(tx, deltas, reason), + updates: await applyUnreadDeltas(tx, deltas, "removed"), }; }); - sendNotificationStates(c, result.updates); - removed += result.count; - if (result.count === 0) break; + 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 index 08751ac62..0480eb15f 100644 --- a/packages/vitnode/src/api/models/notifications/index.ts +++ b/packages/vitnode/src/api/models/notifications/index.ts @@ -2,6 +2,7 @@ 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 { @@ -12,15 +13,9 @@ import type { NotificationsContext } from "./shared"; import { getNotificationState, removeNotificationItems } from "./inbox"; import { publishNotification } from "./publish"; -import { runQueuedTasksNow } from "./run-now"; export type { PublishNotificationArgs, PublishNotificationResult }; -/** - * The Notifications Center on the API context: `c.get("notifications")`. - * Plugins decide *who* might care; this decides who actually receives what, - * and delivers it. - */ export class NotificationsModel { constructor(c: NotificationsContext) { this.c = c; @@ -29,45 +24,19 @@ export class NotificationsModel { protected readonly c: NotificationsContext; private readonly queued: number[] = []; - /** - * Starts delivering what this request published as soon as the response is - * on its way, rather than on the next queue tick. Claims go through the - * queue's own row locks, so this never double-processes; anything it cannot - * claim (rolled back, already taken) is left to the worker. - */ flushAfterResponse(): void { const ids = this.queued.splice(0); if (ids.length === 0) return; - void runQueuedTasksNow(this.c, ids).catch(async (error: unknown) => { + void processQueueTasksByIds(this.c, ids).catch(async (error: unknown) => { await this.c .get("log") - ?.warn( + .warn( `[Notifications] Immediate delivery deferred to the queue: ${error instanceof Error ? error.message : String(error)}`, ); }); } - /** - * Durably records an event and queues its delivery. With `tx` it joins the - * producer's transaction, so a rolled-back write never notifies anyone and a - * committed one always does, even if the process dies right after. - * - * @example - * ```ts - * await c.get("db").transaction(async tx => { - * const [comment] = await tx.insert(comments).values(values).returning(); - * await c.get("notifications").publish({ - * type: commentNotification, - * tx, - * recipients: [post.authorId], - * subject: { type: "blog.post", id: post.id }, - * data: { postId: post.id, title: post.title }, - * idempotencyKey: `comment:${comment.id}`, - * }); - * }); - * ``` - */ async publish<TData>( args: PublishNotificationArgs<TData>, ): Promise<PublishNotificationResult> { @@ -76,12 +45,6 @@ export class NotificationsModel { ); } - /** - * Removes inbox items about a subject - after the content is deleted or a - * group of users lost access - keeping every owner's unread count right. - * Narrow it with `type` and `userIds`; with neither, every recipient's item - * about the subject goes. - */ async remove({ subject, type, diff --git a/packages/vitnode/src/api/models/notifications/maintenance.ts b/packages/vitnode/src/api/models/notifications/maintenance.ts index 0793c99ba..c89d4b0a5 100644 --- a/packages/vitnode/src/api/models/notifications/maintenance.ts +++ b/packages/vitnode/src/api/models/notifications/maintenance.ts @@ -8,7 +8,7 @@ import { } from "@/database/notifications"; import { core_queue } from "@/database/queue"; -import type { NotificationsContext } from "./shared"; +import type { NotificationsContext, NotificationsDb } from "./shared"; import { planNotificationDigests, reclaimStaleDeliveries } from "./email"; import { dispatchEmailDrain } from "./email-queue"; @@ -22,28 +22,13 @@ import { const DAY = 24 * 60 * 60 * 1000; const STALLED_EVENT_MINUTES = 10; -/** Digest receipts nobody claimed in this long are dropped from email. */ -const PENDING_EMAIL_MAX_DAYS = 14; +const UNCLAIMED_EMAIL_RECEIPT_MAX_DAYS = 14; const DELETE_BATCH = 1000; -/** - * Removes inbox items, events and delivery records older than the retention - * period. Items go through `removeNotificationItems`, so unread ones come off - * their owner's count in the same transaction; events are only deleted once - * no inbox item points at them. - */ -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), - }); - +export const deleteUnreferencedEvents = async ( + db: NotificationsDb, + { createdBefore }: { createdBefore?: Date } = {}, +): Promise<number> => { let events = 0; for (;;) { const deleted = await db @@ -56,7 +41,9 @@ export const runNotificationCleanup = async ( .from(core_notification_events) .where( and( - lt(core_notification_events.createdAt, cutoff), + createdBefore + ? lt(core_notification_events.createdAt, createdBefore) + : undefined, inArray(core_notification_events.status, [ "completed", "failed", @@ -82,6 +69,23 @@ export const runNotificationCleanup = async ( 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 @@ -119,7 +123,7 @@ export const runNotificationCleanup = async ( isNull(core_notification_receipts.emailDeliveryId), lt( core_notification_receipts.createdAt, - new Date(now.getTime() - PENDING_EMAIL_MAX_DAYS * DAY), + new Date(now.getTime() - UNCLAIMED_EMAIL_RECEIPT_MAX_DAYS * DAY), ), ), ); @@ -127,11 +131,7 @@ export const runNotificationCleanup = async ( return { deliveries, events, items }; }; -/** - * Re-queues fan-out for events left unfinished with no task to finish them - - * e.g. their task ran out of attempts while the database was down. - */ -export const recoverStalledEvents = async ( +const recoverStalledEvents = async ( c: NotificationsContext, now = new Date(), ): Promise<number> => { @@ -175,7 +175,6 @@ export const recoverStalledEvents = async ( return stalled.length; }; -/** The periodic tick: recovery first, then digests, then make sure email drains. */ export const runNotificationSchedule = async ( c: NotificationsContext, { now = new Date() }: { now?: Date } = {}, diff --git a/packages/vitnode/src/api/models/notifications/preferences.ts b/packages/vitnode/src/api/models/notifications/preferences.ts index 1ed3f3872..e3c00270a 100644 --- a/packages/vitnode/src/api/models/notifications/preferences.ts +++ b/packages/vitnode/src/api/models/notifications/preferences.ts @@ -28,7 +28,6 @@ export interface NotificationTypePreferenceView { categoryLabel: string; defaultEmail: NotificationEmailMode; description: null | string; - /** The modes this type may use here - empty when email is unavailable. */ emailModes: NotificationEmailMode[]; id: string; inAppAvailable: boolean; @@ -37,7 +36,6 @@ export interface NotificationTypePreferenceView { mandatory: boolean; pluginId: string; pushAvailable: boolean; - /** What the user gets now, after defaults and installation policy. */ value: { email: NotificationEmailMode; inApp: boolean; push: boolean }; } @@ -57,25 +55,20 @@ export const translateOr = ( return fallback; }; -/** - * One row per registered type the installation has not switched off, in the - * user's language. Rows are generated from the definitions, never stored: a - * new plugin's types appear without a migration. - */ export const getNotificationPreferences = async ( c: NotificationsContext, { language, userId }: { language?: string; userId: number }, ): Promise<NotificationPreferencesView> => { const db = c.get("db"); - const [settings, [state]] = await Promise.all([ + 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 { t } = await createTranslatorCache(c)(language); const emailConfigured = isEmailConfigured(c); const preferences = state?.preferences ?? {}; @@ -143,11 +136,6 @@ export interface UpdateNotificationPreferencesArgs { types?: Record<string, NotificationTypePreference>; } -/** - * Saves the user's choices, refusing anything the installation does not - * offer: unknown types, email for a type without email, and changes to - * mandatory types. - */ export const updateNotificationPreferences = async ( c: NotificationsContext, userId: number, @@ -204,7 +192,7 @@ export const updateNotificationPreferences = async ( await db.transaction(async tx => { const [current] = await lockUserStates(tx, [userId]); - const merged = { ...current?.preferences }; + const merged = { ...current.preferences }; for (const [typeId, preference] of Object.entries(patch)) { merged[typeId] = { ...merged[typeId], ...preference }; } diff --git a/packages/vitnode/src/api/models/notifications/publish.ts b/packages/vitnode/src/api/models/notifications/publish.ts index edfddc942..80f8975c5 100644 --- a/packages/vitnode/src/api/models/notifications/publish.ts +++ b/packages/vitnode/src/api/models/notifications/publish.ts @@ -17,38 +17,20 @@ import { QUEUE_NOTIFICATIONS_FANOUT, } from "./shared"; -export const MAX_EXPLICIT_RECIPIENTS = 100_000; +const MAX_EXPLICIT_RECIPIENTS = 100_000; export interface PublishNotificationArgs<TData> { - /** - * Who caused it. Defaults to the signed-in admin or user of the request; - * pass `null` for system notifications. - */ actorId?: null | number; - /** - * Deliver to the actor too. Off by default - nobody needs to be told about - * their own comment - but right for "your export is ready" style messages. - */ allowSelf?: boolean; data: NoInfer<TData>; - /** - * Stable for one real-world happening, e.g. `comment:42`. Publishing the - * same key twice for a type is a no-op that returns the first event. - */ idempotencyKey: string; - /** Candidate user ids chosen by the plugin's business rules. */ recipients?: readonly number[]; subject?: NotificationSubject; - /** - * Join the producer's transaction: the event, and the durable task that - * delivers it, commit or roll back with the producer's own write. - */ tx?: NotificationsDb; type: NotificationTypeDefinition<TData> | string; } export interface PublishNotificationResult { - /** `true` when the idempotency key had already been published. */ duplicate: boolean; eventId: number; } @@ -76,7 +58,7 @@ const normalizeSubject = ( return { id, type: subject.type }; }; -export const resolveGroupKey = ( +const resolveGroupKey = ( definition: AnyNotificationTypeDefinition, data: unknown, subject: null | { id: string; type: string }, @@ -91,7 +73,6 @@ export const resolveGroupKey = ( return key ? key.slice(0, 200) : null; }; -/** Validates, deduplicates and durably records one event. */ export const publishNotification = async <TData>( c: NotificationsContext, args: PublishNotificationArgs<TData>, @@ -205,8 +186,6 @@ export const publishNotification = async <TData>( .limit(1); if (!existing) { - // Only reachable when a concurrent transaction holds the key and has - // not committed yet; the caller's retry will see it. throw new NotificationPublishError( `Event "${typeId}" with key "${idempotencyKey}" is being published concurrently.`, ); diff --git a/packages/vitnode/src/api/models/notifications/render.ts b/packages/vitnode/src/api/models/notifications/render.ts index dc10b393c..cdb4bccce 100644 --- a/packages/vitnode/src/api/models/notifications/render.ts +++ b/packages/vitnode/src/api/models/notifications/render.ts @@ -35,14 +35,13 @@ export interface NotificationActor { nameCode: string; } -export interface ActorSummary { +interface ActorSummary { actorCount: number; actors: NotificationActor[]; } const ACTOR_PREVIEW_LIMIT = 3; -/** Loads users for actor previews in one query. */ export const loadActors = async ( c: NotificationsContext, ids: number[], @@ -71,10 +70,6 @@ export const loadActors = async ( ); }; -/** - * The newest distinct actors of each inbox item, and how many distinct actors - * it has in total - one grouped query for a whole page of items. - */ export const loadActorSummaries = async ( c: NotificationsContext, notificationIds: number[], @@ -135,14 +130,13 @@ export const loadActorSummaries = async ( ); }; -export const subjectOf = ( +const subjectOf = ( row: Pick<NotificationEventRow, "subjectId" | "subjectType">, ): NotificationSubject | null => row.subjectType && row.subjectId ? { id: row.subjectId, type: row.subjectType } : null; -/** Which of `userIds` the type still lets see this event. */ export const checkNotificationAccess = async ({ c, data, @@ -162,15 +156,11 @@ export const checkNotificationAccess = async ({ return new Set(await access({ c, data, subject: subjectOf(event), userIds })); }; -export interface ResolvedEvent { +interface ResolvedEvent { data: unknown; registered: RegisteredNotificationType; } -/** - * The registered type and parsed data of an event, or `null` when it can no - * longer be shown: its plugin is gone, or its data no longer parses. - */ export const resolveEvent = ( c: NotificationsContext, event: NotificationEventRow, @@ -214,7 +204,6 @@ const presentArgs = ({ t, }); -/** In-app text for one item, sanitized. `null` if the type failed to render. */ export const presentNotification = ( c: NotificationsContext, args: PresentArgs, @@ -232,7 +221,7 @@ export const presentNotification = ( } catch (error) { void c .get("log") - ?.error( + .error( `[Notifications] "${args.registered.definition.id}" failed to render: ${error instanceof Error ? error.message : String(error)}`, ); @@ -277,7 +266,6 @@ export const presentNotificationEmail = ( } }; -/** Translators per locale, created once per operation. */ export const createTranslatorCache = (c: NotificationsContext) => { const cache = new Map<string, Promise<Translator>>(); diff --git a/packages/vitnode/src/api/models/notifications/run-now.ts b/packages/vitnode/src/api/models/notifications/run-now.ts deleted file mode 100644 index 329dd269d..000000000 --- a/packages/vitnode/src/api/models/notifications/run-now.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { processQueueTasksByIds } from "@/api/modules/queue/helpers/process-queue-tasks"; - -import type { NotificationsContext } from "./shared"; - -export const runQueuedTasksNow = async ( - c: NotificationsContext, - queueIds: number[], -): Promise<void> => { - await processQueueTasksByIds(c, queueIds); -}; diff --git a/packages/vitnode/src/api/models/notifications/shared.ts b/packages/vitnode/src/api/models/notifications/shared.ts index d920adbed..9050d77d8 100644 --- a/packages/vitnode/src/api/models/notifications/shared.ts +++ b/packages/vitnode/src/api/models/notifications/shared.ts @@ -1,7 +1,5 @@ import type { Context } from "hono"; -import { inArray } from "drizzle-orm"; - import type { NotificationGlobalSettings, NotificationTypePolicy, @@ -18,7 +16,6 @@ import { import { core_notification_settings } from "@/database/notifications"; import { notificationsStateChannel } from "@/ws/notifications"; -/** A database handle or a transaction - anything that can run a query. */ export type NotificationsDb = Omit<EnvVitNode["Variables"]["db"], "$client">; export type NotificationsContext = Context<EnvVitNode>; @@ -29,7 +26,9 @@ export const QUEUE_NOTIFICATIONS_EMAIL = "notifications-email"; export const QUEUE_NOTIFICATIONS_CLEANUP = "notifications-cleanup"; export const GLOBAL_SETTINGS_KEY = "global"; -export const typeSettingsKey = (type: string): string => `type:${type}`; +const TYPE_SETTINGS_KEY_PREFIX = "type:"; +export const typeSettingsKey = (type: string): string => + `${TYPE_SETTINGS_KEY_PREFIX}${type}`; export const getNotificationRegistry = ( c: NotificationsContext, @@ -45,17 +44,15 @@ export interface NotificationSettingsSnapshot { policies: Map<string, NotificationTypePolicy>; } -export const normalizeGlobalSettings = ( +const normalizeGlobalSettings = ( value: Record<string, unknown> | undefined, -): NotificationGlobalSettings => { - const defaults = DEFAULT_NOTIFICATION_SETTINGS; - - return { - paused: typeof value?.paused === "boolean" ? value.paused : defaults.paused, - }; -}; +): NotificationGlobalSettings => ({ + paused: + typeof value?.paused === "boolean" + ? value.paused + : DEFAULT_NOTIFICATION_SETTINGS.paused, +}); -/** One query: the global settings and every type policy. */ export const loadNotificationSettings = async ( db: NotificationsDb, ): Promise<NotificationSettingsSnapshot> => { @@ -65,8 +62,8 @@ export const loadNotificationSettings = async ( for (const row of rows) { if (row.key === GLOBAL_SETTINGS_KEY) global = row.value; - else if (row.key.startsWith("type:")) { - policies.set(row.key.slice(5), row.value); + else if (row.key.startsWith(TYPE_SETTINGS_KEY_PREFIX)) { + policies.set(row.key.slice(TYPE_SETTINGS_KEY_PREFIX.length), row.value); } } @@ -78,14 +75,9 @@ export const getNotificationWorkers = ( ): NotificationWorkerSettings => c.get("core").notificationWorkers ?? resolveNotificationWorkerSettings(); -/** Whether notification emails can be sent at all on this installation. */ export const isEmailConfigured = (c: NotificationsContext): boolean => !!c.get("core").email?.adapter; -/** - * Sends each user their committed unread state. Call only after the - * transaction that produced `states` has committed. - */ export const sendNotificationStates = ( c: NotificationsContext, states: Iterable<NotificationStateMessage & { userId: number }>, @@ -95,14 +87,3 @@ export const sendNotificationStates = ( realtime.sendToUser(userId, notificationsStateChannel, message); } }; - -export const loadSettingsForKeys = async ( - db: NotificationsDb, - keys: string[], -) => - keys.length === 0 - ? [] - : await db - .select() - .from(core_notification_settings) - .where(inArray(core_notification_settings.key, keys)); diff --git a/packages/vitnode/src/api/models/notifications/stats.ts b/packages/vitnode/src/api/models/notifications/stats.ts index a36be0f1f..c10e6eaf9 100644 --- a/packages/vitnode/src/api/models/notifications/stats.ts +++ b/packages/vitnode/src/api/models/notifications/stats.ts @@ -1,4 +1,13 @@ -import { and, gte, inArray, isNull, ne, or, sql } from "drizzle-orm"; +import { + and, + gte, + inArray, + isNull, + ne, + or, + sql, + type SQLWrapper, +} from "drizzle-orm"; import { core_notification_deliveries, @@ -169,7 +178,7 @@ export const getNotificationStats = async ( now.getTime() - (count * 2 + 2) * (unit === "day" ? DAY : HOUR), ); const format = unit === "day" ? "YYYY-MM-DD" : 'YYYY-MM-DD"T"HH24'; - const bucketOf = (column: unknown) => + const bucketOf = (column: SQLWrapper) => sql<string>`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})`; @@ -214,11 +223,9 @@ export const getNotificationStats = async ( count: row.count, key: row.key, metric: - row.status === "sent" - ? ("sent" as const) - : row.status === "failed" - ? ("failed" as const) - : ("skipped" as const), + row.status === "sent" || row.status === "failed" + ? row.status + : ("skipped" as const), })), ...eventRows.map(row => ({ count: row.count, diff --git a/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts b/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts index 1ce76da14..bc3c593d1 100644 --- a/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts @@ -11,12 +11,12 @@ import { } from "@/api/models/notifications/danger"; import { CONFIG_PLUGIN } from "@/config"; -const permission = { +const canManageNotifications = { module: "notifications", permission: "can_manage", } as const; -const json = <T extends z.ZodType>(schema: T, description: string) => +const dangerResponses = <T extends z.ZodType>(schema: T, description: string) => ({ 200: { content: { "application/json": { schema } }, description }, 403: { description: "Access Denied" }, @@ -24,13 +24,13 @@ const json = <T extends z.ZodType>(schema: T, description: string) => export const pauseNotificationsRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: permission, + 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: json(z.object({ success: z.boolean() }), "Paused"), + responses: dangerResponses(z.object({ success: z.boolean() }), "Paused"), }, handler: async c => { await pauseNotifications(c); @@ -41,39 +41,45 @@ export const pauseNotificationsRoute = buildRoute({ export const resumeNotificationsRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: permission, + adminStaffPermission: canManageNotifications, route: { method: "post", description: "Resume notifications and queue every event that waited while they were paused.", path: "/resume", - responses: json(z.object({ requeuedEvents: z.number() }), "Resumed"), + 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: permission, + 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: json(z.object({ cancelled: z.number() }), "Cancelled"), + 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: permission, + adminStaffPermission: canManageNotifications, route: { method: "post", description: "Mark every unread notification as read for every member and zero their badges.", path: "/read-all", - responses: json( + responses: dangerResponses( z.object({ items: z.number(), members: z.number() }), "Marked as read", ), @@ -83,13 +89,13 @@ export const markEverythingReadRoute = buildRoute({ export const deleteAllNotificationsRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: permission, + adminStaffPermission: canManageNotifications, route: { method: "post", description: "Delete every member's notifications. Types, preferences, settings and email history are kept.", path: "/delete-all", - responses: json( + responses: dangerResponses( z.object({ events: z.number(), items: z.number() }), "Deleted", ), @@ -99,13 +105,13 @@ export const deleteAllNotificationsRoute = buildRoute({ export const resetMemberNotificationPreferencesRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: permission, + 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: json(z.object({ members: z.number() }), "Reset"), + 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 index 66da47a94..68c824e2e 100644 --- a/packages/vitnode/src/api/modules/admin/notifications/routes/deliveries.route.ts +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/deliveries.route.ts @@ -6,14 +6,11 @@ import { retryNotificationDelivery, } from "@/api/models/notifications/admin"; import { CONFIG_PLUGIN } from "@/config"; +import { core_notification_deliveries } from "@/database/notifications"; -const DELIVERY_STATUSES = [ - "pending", - "sending", - "sent", - "failed", - "skipped", -] as const; +const zodDeliveryStatus = z.enum( + core_notification_deliveries.status.enumValues, +); export const listNotificationDeliveriesRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, @@ -27,7 +24,7 @@ export const listNotificationDeliveriesRoute = buildRoute({ query: z.object({ cursor: z.coerce.number().int().positive().optional(), limit: z.coerce.number().int().min(1).max(100).default(25), - status: z.enum(DELIVERY_STATUSES).optional(), + status: zodDeliveryStatus.optional(), }), }, responses: { @@ -44,11 +41,11 @@ export const listNotificationDeliveriesRoute = buildRoute({ itemCount: z.number(), lastError: z.string().nullable(), maxAttempts: z.number(), - mode: z.enum(["immediate", "daily", "weekly", "test"]), + mode: z.enum(core_notification_deliveries.mode.enumValues), providerMessageId: z.string().nullable(), sentAt: z.date().nullable(), skipReason: z.string().nullable(), - status: z.enum(DELIVERY_STATUSES), + status: zodDeliveryStatus, type: z.string().nullable(), updatedAt: z.date(), userId: z.number(), 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 index 76dbf188e..e26b8b365 100644 --- a/packages/vitnode/src/api/modules/admin/notifications/routes/overview.route.ts +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/overview.route.ts @@ -2,14 +2,11 @@ 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"; -import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types"; -const zodEmailMode = z.enum(NOTIFICATION_EMAIL_MODES); const zodCounts = z.record(z.string(), z.number()); -export const zodNotificationGlobalSettings = z.object({ paused: z.boolean() }); - export const getNotificationsOverviewRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, adminStaffPermission: { module: "notifications", permission: "can_view" }, @@ -40,7 +37,7 @@ export const getNotificationsOverviewRoute = buildRoute({ oldestPendingEventAt: z.date().nullable(), queue: zodCounts, }), - settings: zodNotificationGlobalSettings, + settings: z.object({ paused: z.boolean() }), workers: z.object({ emailBatchSize: z.number(), emailConcurrency: z.number(), @@ -51,7 +48,7 @@ export const getNotificationsOverviewRoute = buildRoute({ z.object({ category: z.string(), defaults: z.object({ - email: zodEmailMode, + email: zodNotificationEmailMode, inApp: z.boolean(), }), emailAvailable: z.boolean(), @@ -65,7 +62,7 @@ export const getNotificationsOverviewRoute = buildRoute({ allowEmail: z.boolean(), allowInApp: z.boolean(), allowPush: z.boolean(), - email: zodEmailMode.nullable(), + email: zodNotificationEmailMode.nullable(), enabled: z.boolean(), inApp: z.boolean().nullable(), memberCanEdit: z.boolean(), 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 index af18e184b..4139c2047 100644 --- a/packages/vitnode/src/api/modules/admin/notifications/routes/settings.route.ts +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/settings.route.ts @@ -2,8 +2,8 @@ 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"; -import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types"; export const updateNotificationTypePolicyRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, @@ -23,7 +23,7 @@ export const updateNotificationTypePolicyRoute = buildRoute({ allowEmail: z.boolean().optional(), allowInApp: z.boolean().optional(), allowPush: z.boolean().optional(), - email: z.enum(NOTIFICATION_EMAIL_MODES).optional(), + email: zodNotificationEmailMode.optional(), enabled: z.boolean().optional(), inApp: z.boolean().optional(), memberCanEdit: z.boolean().optional(), 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 97ce6ccfa..852bf711e 100644 --- a/packages/vitnode/src/api/modules/admin/routes/notifications.route.ts +++ b/packages/vitnode/src/api/modules/admin/routes/notifications.route.ts @@ -56,16 +56,13 @@ export const sendNotificationRoute = buildRoute({ const { userId, title, description, type } = c.req.valid("json"); - // The toast this route always sent, unchanged: delivered only to that - // user's open connections, across all their browsers. + // Delivered only to that user's connections, across all their browsers. c.get("realtime").sendToUser(userId, notificationsChannel, { description, title, type, }); - // New: the same message is kept in their inbox, so a user who was offline - // (or closed the toast) still sees it. Each send is its own message. await c.get("notifications").publish({ type: adminMessageNotification, actorId: admin.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 index 9a4d3b612..c0bd5c0a0 100644 --- a/packages/vitnode/src/api/modules/notifications/routes/mark.route.ts +++ b/packages/vitnode/src/api/modules/notifications/routes/mark.route.ts @@ -39,7 +39,6 @@ export const markNotificationReadRoute = buildRoute({ content: { "application/json": { schema: z.object({ - /** The `activitySeq` the client rendered. */ throughSeq: z.number().int().min(1).optional(), }), }, diff --git a/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts b/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts index ab24d27a1..cdcfb6c0a 100644 --- a/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts +++ b/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts @@ -64,7 +64,7 @@ export const getNotificationPreferencesRoute = buildRoute({ }, }); -export const zodUpdateNotificationPreferences = z.object({ +const zodUpdateNotificationPreferences = z.object({ types: z .record( z.string().max(100), diff --git a/packages/vitnode/src/api/modules/notifications/schema.ts b/packages/vitnode/src/api/modules/notifications/schema.ts index 067efb2ac..538196838 100644 --- a/packages/vitnode/src/api/modules/notifications/schema.ts +++ b/packages/vitnode/src/api/modules/notifications/schema.ts @@ -12,12 +12,12 @@ export const zodNotificationState = z.object({ unread: z.number(), }); -export const zodNotificationSubject = z.object({ +const zodNotificationSubject = z.object({ id: z.string(), type: z.string(), }); -export const zodNotificationActor = z.object({ +const zodNotificationActor = z.object({ avatarColor: z.string(), avatarUrl: z.string().nullable(), id: z.number(), @@ -47,8 +47,6 @@ export const zodNotificationItem = z.object({ export const zodNotificationEmailMode = z.enum(NOTIFICATION_EMAIL_MODES); -export const zodSubscriptionState = z.enum(["following", "muted", "none"]); - export const zodNotificationIdParams = z.object({ id: z.coerce.number().int().positive(), }); @@ -57,14 +55,9 @@ export const unauthorizedResponse = { 401: { description: "Not signed in" }, } as const; -/** The signed-in user - every inbox route acts on them and nobody else. */ export const requireNotificationUser = (c: Context<EnvVitNode>) => { const user = c.get("user"); if (!user) throw new HTTPException(401, { message: "Unauthorized" }); return user; }; - -export const jsonContent = <T extends z.ZodType>(schema: T) => ({ - content: { "application/json": { schema } }, -}); diff --git a/packages/vitnode/src/api/modules/notifications/tasks/notification-tasks.ts b/packages/vitnode/src/api/modules/notifications/tasks/notification-tasks.ts index 3ce0038bd..8a066bb00 100644 --- a/packages/vitnode/src/api/modules/notifications/tasks/notification-tasks.ts +++ b/packages/vitnode/src/api/modules/notifications/tasks/notification-tasks.ts @@ -26,8 +26,6 @@ export const notificationsFanoutTask = buildQueueTask({ const { eventId } = fanoutPayloadSchema.parse(payload); const result = await processNotificationEvent(c, eventId); - // Out of time with recipients left: progress is saved on the event, so a - // fresh task picks up at the next batch. if (!result.done) { await c.get("queue").dispatch({ name: QUEUE_NOTIFICATIONS_FANOUT, 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 991ed183c..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 @@ -12,16 +12,10 @@ const QUEUE_BATCH_SIZE = 25; const QUEUE_LOCK_KEY = "queue:process"; const QUEUE_LOCK_TTL_SECONDS = 55; const QUEUE_RETENTION_DAYS = 7; -/** - * A task still `processing` after this long belonged to a worker that died - * mid-run. Handlers must stay well under it - long work saves progress and - * re-queues itself instead. - */ -export const QUEUE_STALE_PROCESSING_MINUTES = 15; +const QUEUE_STALE_PROCESSING_MINUTES = 15; type QueueRow = typeof core_queue.$inferSelect; -/** Claims due tasks matching `where`, skipping rows another worker holds. */ const claimQueueTasks = async ( c: Context<EnvVitNode>, where: SQL | undefined, @@ -107,27 +101,22 @@ const runClaimedQueueTasks = async ( } }; -/** - * Puts tasks whose worker died mid-run back in line - or fails them once - * they have used every attempt - so a crash never strands a task in - * `processing` forever. - */ -export const reclaimStaleQueueTasks = async ( +const reclaimStaleQueueTasks = async ( c: Context<EnvVitNode>, - now: Date = new Date(), -): Promise<number> => { + now: Date, +): Promise<void> => { const stale = lt( core_queue.reservedAt, new Date(now.getTime() - QUEUE_STALE_PROCESSING_MINUTES * 60_000), ); - const message = "The worker stopped before the task finished."; + const staleTaskError = "The worker stopped before the task finished."; const db = c.get("db"); - const failed = await db + await db .update(core_queue) .set({ completedAt: now, - lastError: message, + lastError: staleTaskError, reservedAt: null, status: "failed", }) @@ -137,28 +126,19 @@ export const reclaimStaleQueueTasks = async ( stale, sql`${core_queue.attempts} >= ${core_queue.maxAttempts}`, ), - ) - .returning({ id: core_queue.id }); + ); - const retried = await db + await db .update(core_queue) .set({ availableAt: now, - lastError: message, + lastError: staleTaskError, reservedAt: null, status: "pending", }) - .where(and(eq(core_queue.status, "processing"), stale)) - .returning({ id: core_queue.id }); - - return failed.length + retried.length; + .where(and(eq(core_queue.status, "processing"), stale)); }; -/** - * Runs specific tasks right away, outside the worker's tick - used to start a - * request's own work once its response is sent. Uses the same claim, so a task - * the worker already took (or that never committed) is simply skipped. - */ export const processQueueTasksByIds = async ( c: Context<EnvVitNode>, ids: number[], 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 10a7a2615..055c65636 100644 --- a/packages/vitnode/src/api/modules/users/routes/session.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/session.route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { isStaff } 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({ @@ -38,15 +39,7 @@ export const sessionRoute = buildRoute({ birthday: z.date().nullable(), isAdmin: z.boolean(), isModerator: z.boolean(), - /** - * The canonical unread count and its revision. A client keeps - * whichever state - this or a WebSocket update - has the - * higher revision. - */ - notifications: z.object({ - revision: z.number(), - unread: z.number(), - }), + notifications: zodNotificationState, }) .nullable(), }), @@ -63,8 +56,6 @@ export const sessionRoute = buildRoute({ const [isAdmin, isModerator, notifications] = await Promise.all([ isStaff(c, { type: "admin", userId: user.id }), isStaff(c, { type: "moderator", userId: user.id }), - // One primary-key read - never cached with the session user, so a - // count changed by another tab or device is never served stale. getNotificationState(c.get("db"), user.id), ]); diff --git a/packages/vitnode/src/components/reveal-panel.tsx b/packages/vitnode/src/components/reveal-panel.tsx new file mode 100644 index 000000000..fa99cb637 --- /dev/null +++ b/packages/vitnode/src/components/reveal-panel.tsx @@ -0,0 +1,44 @@ +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; + +import { + REVEAL_EXIT_TRANSITION, + REVEAL_HIDDEN, + REVEAL_SHOWN, + REVEAL_TRANSITION, +} from "@/lib/motion"; + +export const RevealPanel = ({ + children, + className, + id, + open, +}: { + children: React.ReactNode; + className?: string; + id: string; + open: boolean; +}) => { + const shouldReduceMotion = useReducedMotion(); + + return ( + <AnimatePresence initial={false}> + {open ? ( + <motion.div + animate={REVEAL_SHOWN} + className={className} + exit={ + shouldReduceMotion + ? undefined + : { ...REVEAL_HIDDEN, transition: REVEAL_EXIT_TRANSITION } + } + id={id} + initial={shouldReduceMotion ? false : REVEAL_HIDDEN} + key="panel" + transition={REVEAL_TRANSITION} + > + {children} + </motion.div> + ) : null} + </AnimatePresence> + ); +}; diff --git a/packages/vitnode/src/database/notifications.ts b/packages/vitnode/src/database/notifications.ts index cf472dd51..ca757b224 100644 --- a/packages/vitnode/src/database/notifications.ts +++ b/packages/vitnode/src/database/notifications.ts @@ -5,11 +5,6 @@ import type { NotificationEmailMode } from "@/lib/notifications/types"; import { core_users } from "./users"; -/** - * One published notification event, stored once however many people receive - * it. `recipientIds` are the candidates the publisher named; the fan-out - * cursor records how far delivery got, so a retry resumes there. - */ export const core_notification_events = camelCase.table.withRLS( "core_notification_events", t => ({ @@ -38,7 +33,6 @@ export const core_notification_events = camelCase.table.withRLS( }) .notNull() .default("pending"), - /** Index into `recipientIds` processed so far. */ recipientCursor: t.integer().notNull().default(0), deliveredCount: t.integer().notNull().default(0), lastError: t.text(), @@ -56,11 +50,6 @@ export const core_notification_events = camelCase.table.withRLS( ], ); -/** - * A recipient's inbox entry. Ungrouped events get one row each; grouped events - * share one row per recipient, type, group key and time bucket. Unread means - * `readSeq < activitySeq` on a row that is not archived. - */ export const core_notifications = camelCase.table.withRLS( "core_notifications", t => ({ @@ -76,8 +65,6 @@ export const core_notifications = camelCase.table.withRLS( groupBucket: t.bigint({ mode: "number" }).notNull().default(0), subjectType: t.varchar({ length: 100 }), subjectId: t.varchar({ length: 100 }), - // Restrict, not cascade: deleting an event must never drop unread items - // behind the counter's back. Cleanup removes items first, then events. latestEventId: t .bigint({ mode: "number" }) .references(() => core_notification_events.id, { onDelete: "restrict" }) @@ -110,11 +97,6 @@ export const core_notifications = camelCase.table.withRLS( ], ); -/** - * One row per event and recipient: the deduplication key for fan-out, the - * membership of an event in a grouped inbox item, and the email state of that - * event for that person. - */ export const core_notification_receipts = camelCase.table.withRLS( "core_notification_receipts", t => ({ @@ -126,13 +108,10 @@ export const core_notification_receipts = camelCase.table.withRLS( .integer() .references(() => core_users.id, { onDelete: "cascade" }) .notNull(), - /** `null` when the user takes this type by email only. */ notificationId: t .bigint({ mode: "number" }) .references(() => core_notifications.id, { onDelete: "cascade" }), - /** `activitySeq` of the inbox item right after this event joined it. */ seq: t.integer(), - /** Waiting for an email (immediate or digest) that has not claimed it. */ emailPending: t.boolean().notNull().default(false), emailDeliveryId: t.bigint({ mode: "number" }), createdAt: t.timestamp().notNull().defaultNow(), @@ -156,11 +135,6 @@ export interface NotificationTypePreference { push?: boolean; } -/** - * Everything notifications keep per user in one row: the canonical unread - * count and its revision, digest schedule, and per-type preferences. Every - * inbox write locks this row first, which serializes a user's inbox changes. - */ export const core_notification_user_state = camelCase.table.withRLS( "core_notification_user_state", t => ({ @@ -180,7 +154,6 @@ export const core_notification_user_state = camelCase.table.withRLS( t => [index("core_notification_user_state_unread_idx").on(t.unreadCount)], ); -/** One email send - immediate, digest or test - and its attempts. */ export const core_notification_deliveries = camelCase.table.withRLS( "core_notification_deliveries", t => ({ @@ -226,7 +199,6 @@ export const core_notification_deliveries = camelCase.table.withRLS( ], ); -/** Installation policy: `global`, and one `type:{typeId}` per type. */ export const core_notification_settings = camelCase.table.withRLS( "core_notification_settings", t => ({ diff --git a/packages/vitnode/src/emails/notification.tsx b/packages/vitnode/src/emails/notification.tsx index 10543456f..4519c0c1a 100644 --- a/packages/vitnode/src/emails/notification.tsx +++ b/packages/vitnode/src/emails/notification.tsx @@ -8,7 +8,6 @@ import { EmailButton } from "./ui/button"; export interface NotificationEmailEntry { body?: string; title: string; - /** Absolute URL, already restricted to this site. */ url: null | string; } @@ -21,11 +20,6 @@ export interface NotificationEmailProps extends DefaultTemplateEmailProps { preferencesUrl: string; } -/** - * One template for an immediate email (one entry) and a digest (many). All - * text arrives as plain strings, so React escapes it - nothing a plugin or an - * actor wrote can inject markup. - */ export default function NotificationEmailTemplate({ actionLabel, entries, @@ -44,7 +38,6 @@ export default function NotificationEmailTemplate({ {intro ? <Text className="text-muted-foreground">{intro}</Text> : null} {entries.map((entry, index) => ( - // A rendered email is never re-ordered, and titles may repeat. // eslint-disable-next-line @eslint-react/no-array-index-key <Section key={index}> {index > 0 ? <Hr className="border-border" /> : null} diff --git a/packages/vitnode/src/lib/api/cron-health.ts b/packages/vitnode/src/lib/api/cron-health.ts index 808f92d22..4c2492d06 100644 --- a/packages/vitnode/src/lib/api/cron-health.ts +++ b/packages/vitnode/src/lib/api/cron-health.ts @@ -1,6 +1,6 @@ import { getNextCronRunDate } from "./get-next-cron-run-date"; -export const CRON_OVERDUE_GRACE_MS = 15 * 60 * 1000; +const CRON_OVERDUE_GRACE_MS = 15 * 60 * 1000; export interface CronJobTiming { createdAt: Date; diff --git a/packages/vitnode/src/lib/notifications/types.ts b/packages/vitnode/src/lib/notifications/types.ts index 23ab299ca..e88e36736 100644 --- a/packages/vitnode/src/lib/notifications/types.ts +++ b/packages/vitnode/src/lib/notifications/types.ts @@ -7,14 +7,6 @@ export const NOTIFICATION_EMAIL_MODES = [ export type NotificationEmailMode = (typeof NOTIFICATION_EMAIL_MODES)[number]; -export const NOTIFICATION_CATEGORIES = [ - "account", - "content", - "social", - "system", -] as const; - -/** Why a user's unread state changed - sent with every realtime update. */ export type NotificationStateReason = | "archived" | "created" @@ -24,12 +16,7 @@ export type NotificationStateReason = | "removed" | "unread"; -/** The canonical unread state of one user, as the server last committed it. */ export interface NotificationState { - /** - * Increases with every committed inbox change for the user. A client keeps - * whichever state has the highest revision and drops anything older. - */ revision: number; unread: number; } @@ -43,6 +30,3 @@ export interface NotificationSubject { id: number | string; type: string; } - -/** Day-of-week numbers as `Date#getUTCDay` returns them. */ -export const NOTIFICATION_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6] as const; diff --git a/packages/vitnode/src/pages/admin/system/notifications.tsx b/packages/vitnode/src/pages/admin/system/notifications.tsx index 27f9e683b..de424a725 100644 --- a/packages/vitnode/src/pages/admin/system/notifications.tsx +++ b/packages/vitnode/src/pages/admin/system/notifications.tsx @@ -12,12 +12,7 @@ const AdminNotificationsPage = ({ <AdminNotificationsRouteContent {...loaderData} /> ); -/** - * The heading's strings come from the loader, so the `<h1>` and the `<title>` - * are the same string by construction. - */ export const route = defineAdminRoute<AdminNotificationsRouteData>({ - // `head` after `load`, always. load: async ({ context, t }) => await loadAdminNotificationsRoute({ ...context, t }), head: ({ loaderData }) => ({ ...loaderData }), diff --git a/packages/vitnode/src/pages/notifications.tsx b/packages/vitnode/src/pages/notifications.tsx index cdeea9ae1..6bb97e654 100644 --- a/packages/vitnode/src/pages/notifications.tsx +++ b/packages/vitnode/src/pages/notifications.tsx @@ -24,7 +24,6 @@ export const route = defineAuthenticatedRoute< NotificationsRouteData, NotificationsRouteSearch >({ - // `head` after `load`, always. load: async ({ context, search, t }) => await loadNotificationsRoute({ ...context, search, t }), head: ({ loaderData }) => ({ robots: "noindex, nofollow", ...loaderData }), diff --git a/packages/vitnode/src/tanstack/admin/cron/query.ts b/packages/vitnode/src/tanstack/admin/cron/query.ts index 000037286..37a8fbdb0 100644 --- a/packages/vitnode/src/tanstack/admin/cron/query.ts +++ b/packages/vitnode/src/tanstack/admin/cron/query.ts @@ -10,7 +10,6 @@ import type { } from "@/views/admin/views/core/advanced/cron/run-action/run-cron"; import { - cronHealthQueryOptions, cronQueryOptions, cronQueryRoot, fetchCronPage, @@ -20,8 +19,6 @@ import { runCronInBrowser } from "@/views/admin/views/core/advanced/cron/run-act export const cronQuery = ({ params }: { params: CronParams }) => cronQueryOptions({ fetchPage: fetchCronPage, params }); -export const cronHealthQuery = () => cronHealthQueryOptions(); - export const invalidateCron = async (queryClient: QueryClient): Promise<void> => await queryClient.invalidateQueries({ queryKey: cronQueryRoot }); diff --git a/packages/vitnode/src/tanstack/admin/cron/route.tsx b/packages/vitnode/src/tanstack/admin/cron/route.tsx index 7cac07979..4680fc4b4 100644 --- a/packages/vitnode/src/tanstack/admin/cron/route.tsx +++ b/packages/vitnode/src/tanstack/admin/cron/route.tsx @@ -1,10 +1,12 @@ 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"; -import { cronHealthQuery, cronQuery } from "./query"; +import { cronQuery } from "./query"; export const ADMIN_CRON_NAMESPACES = [ "admin.advanced.cron", @@ -40,7 +42,7 @@ export const loadAdminCronRoute = async ({ staleTime: "static", }), queryClient.query({ - ...cronHealthQuery(), + ...cronHealthQueryOptions(), staleTime: "static", }), ]); diff --git a/packages/vitnode/src/tanstack/admin/cron/screen.tsx b/packages/vitnode/src/tanstack/admin/cron/screen.tsx index 5fc5fa155..905dc1c8f 100644 --- a/packages/vitnode/src/tanstack/admin/cron/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/cron/screen.tsx @@ -5,6 +5,7 @@ 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"; @@ -13,7 +14,7 @@ import type { AdminCronRouteData } from "./route"; import type { CronRouteSearch, UncheckedCronSearch } from "./route-search"; import { RouteMessages } from "../../i18n/route-messages"; -import { cronHealthQuery, cronQuery, useCronRunCallback } from "./query"; +import { cronQuery, useCronRunCallback } from "./query"; import { ADMIN_CRON_NAMESPACES } from "./route"; import { cronSearchFrom, cronSearchParams } from "./route-search"; @@ -30,7 +31,7 @@ export const AdminCronRouteContent = ({ title, }: AdminCronRouteProps) => { const { data } = useSuspenseQuery(cronQuery({ params })); - const { data: health } = useSuspenseQuery(cronHealthQuery()); + const { data: health } = useSuspenseQuery(cronHealthQueryOptions()); const onRun = useCronRunCallback(); const navigation = React.useMemo<DataTableNavigation>( diff --git a/packages/vitnode/src/tanstack/admin/notifications/query.ts b/packages/vitnode/src/tanstack/admin/notifications/query.ts index 2ba26a55d..012bc0d0e 100644 --- a/packages/vitnode/src/tanstack/admin/notifications/query.ts +++ b/packages/vitnode/src/tanstack/admin/notifications/query.ts @@ -8,7 +8,6 @@ import type { NotificationsAdminActions } from "@/views/admin/views/core/system/ import { notificationsAdminActionsInBrowser } from "@/views/admin/views/core/system/notifications/notifications-mutations"; import { notificationsAdminQueryRoot } from "@/views/admin/views/core/system/notifications/notifications-query"; -/** Refreshes the overview and every cached activity range. */ export const invalidateNotificationsAdmin = async ( queryClient: QueryClient, ): Promise<void> => @@ -16,15 +15,11 @@ export const invalidateNotificationsAdmin = async ( queryKey: notificationsAdminQueryRoot, }); -/** - * The screen's writes, each followed by a refresh of what it changed. A failed - * write refreshes nothing - the cache still describes the server. - */ export const useNotificationsAdminActions = (): NotificationsAdminActions => { const queryClient = useQueryClient(); return React.useMemo<NotificationsAdminActions>(() => { - const refreshing = + const refreshOnSuccess = <Args extends unknown[], Result extends { error?: string }>( action: (...args: Args) => Promise<Result>, ) => @@ -40,14 +35,14 @@ export const useNotificationsAdminActions = (): NotificationsAdminActions => { const browser = notificationsAdminActionsInBrowser; return { - cancelQueuedEmails: refreshing(browser.cancelQueuedEmails), - deleteAll: refreshing(browser.deleteAll), - markEverythingRead: refreshing(browser.markEverythingRead), - pause: refreshing(browser.pause), - resetMemberPreferences: refreshing(browser.resetMemberPreferences), - resume: refreshing(browser.resume), - sendTestEmail: refreshing(browser.sendTestEmail), - updateTypePolicy: refreshing(browser.updateTypePolicy), + 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 index e03ec5bf6..b7341c525 100644 --- a/packages/vitnode/src/tanstack/admin/notifications/route.tsx +++ b/packages/vitnode/src/tanstack/admin/notifications/route.tsx @@ -10,23 +10,16 @@ import type { AdminScreenContext } from "../screen"; import { getIntlRuntime } from "../../i18n/runtime"; import { requireAdminPermission } from "../screen"; -/** - * `/admin/core/system/notifications`, as everything a TanStack Start route - * needs and nothing a route owns. - */ - export const ADMIN_NOTIFICATIONS_NAMESPACES = [ "admin.system.notifications", "core.global", ] as const; -/** What {@link loadAdminNotificationsRoute} returns - and what `head` receives. */ export interface AdminNotificationsRouteData { description: string; title: string; } -/** The core plugin's permission module every tuple on this screen uses. */ export const NOTIFICATIONS_MODULE = "notifications"; const NOTIFICATIONS_VIEW_PERMISSION = { diff --git a/packages/vitnode/src/tanstack/notifications/actions.ts b/packages/vitnode/src/tanstack/notifications/actions.ts index c8c89826a..404f63488 100644 --- a/packages/vitnode/src/tanstack/notifications/actions.ts +++ b/packages/vitnode/src/tanstack/notifications/actions.ts @@ -22,11 +22,6 @@ import { patchCachedNotification, } from "./cache"; -/** - * Inbox actions for one user. Each applies the server's answer - an absolute - * count and revision - to the shared store, and on any failure re-reads the - * authoritative state, so a lost request never leaves the bell wrong. - */ export const useNotificationActions = (userId: number) => { const queryClient = useQueryClient(); const router = useRouter(); @@ -39,11 +34,8 @@ export const useNotificationActions = (userId: number) => { const recover = async () => { toast.error(tErrors("title"), { description: t("action_failed") }); - try { - apply(await fetchNotificationState()); - } catch { - // The next realtime message or session read corrects it. - } + const latest = await fetchNotificationState().catch(() => null); + if (latest) apply(latest); await invalidateNotificationLists(queryClient, userId); }; @@ -79,11 +71,6 @@ export const useNotificationActions = (userId: number) => { if (ok) toast.success(t("all_read"), { description: t("all_read_desc") }); }, - /** - * The only way a click marks something read. `throughSeq` is the - * activity the user saw, so a grouped event that arrived meanwhile keeps - * the item unread. - */ open: (item: NotificationItemView, { newTab }: { newTab: boolean }) => { if (item.unread) { void run( diff --git a/packages/vitnode/src/tanstack/notifications/bell.test.tsx b/packages/vitnode/src/tanstack/notifications/bell.test.tsx index 569b9ae08..a9f005914 100644 --- a/packages/vitnode/src/tanstack/notifications/bell.test.tsx +++ b/packages/vitnode/src/tanstack/notifications/bell.test.tsx @@ -47,7 +47,6 @@ const mount = async () => { queryClient.setQueryData(SESSION_QUERY_KEY, { user: { id: USER_ID, notifications: { revision: 4, unread: 2 } }, }); - // What the dropdown shows once it opens. queryClient.setQueryData( recentNotificationsQueryOptions({ userId: USER_ID }).queryKey, { @@ -78,7 +77,7 @@ const mount = async () => { }; describe("NotificationsBell", () => { - const fetchMock = vi.fn( + const fetchMock = vi.fn<typeof fetch>( async () => await Promise.resolve( new Response(JSON.stringify({ revision: 5, unread: 1 }), { @@ -121,15 +120,11 @@ describe("NotificationsBell", () => { fireEvent.click(await screen.findByText("Two new replies")); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); - const [url, init] = fetchMock.mock.calls[0] as unknown as [ - string | URL, - RequestInit, - ]; + 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 }); - // The answer's absolute count replaces the badge. 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 index 571de7c3b..027637be9 100644 --- a/packages/vitnode/src/tanstack/notifications/bell.tsx +++ b/packages/vitnode/src/tanstack/notifications/bell.tsx @@ -20,7 +20,6 @@ const SignedInBell = ({ const [open, setOpen] = React.useState(false); const state = useNotificationState(userId); const actions = useNotificationActions(userId); - // Fetched only while open: the bell's number never needs the list. const recent = useQuery({ ...recentNotificationsQueryOptions({ userId }), enabled: open, @@ -46,7 +45,6 @@ const SignedInBell = ({ ); }; -/** The header bell for the signed-in user; nothing for guests. */ export const NotificationsBell = () => { const { data: session } = useSessionQuery(); const user = session?.user; diff --git a/packages/vitnode/src/tanstack/notifications/breadcrumb.tsx b/packages/vitnode/src/tanstack/notifications/breadcrumb.tsx index b2ee5ef51..18cab2753 100644 --- a/packages/vitnode/src/tanstack/notifications/breadcrumb.tsx +++ b/packages/vitnode/src/tanstack/notifications/breadcrumb.tsx @@ -1,6 +1,5 @@ import { useTranslations } from "use-intl"; -/** The page's crumb. Its strings come from the route's own namespaces. */ export const NotificationsBreadcrumb = () => { const t = useTranslations("core.notifications"); diff --git a/packages/vitnode/src/tanstack/notifications/cache.ts b/packages/vitnode/src/tanstack/notifications/cache.ts index 09a67f23a..cf26ab170 100644 --- a/packages/vitnode/src/tanstack/notifications/cache.ts +++ b/packages/vitnode/src/tanstack/notifications/cache.ts @@ -24,7 +24,6 @@ const mapPages = ( return { ...data, items: map(data.items) }; }; -/** Patches one item in every cached list - the bell's and the page's. */ export const patchCachedNotification = ( queryClient: QueryClient, userId: number, diff --git a/packages/vitnode/src/tanstack/notifications/namespaces.ts b/packages/vitnode/src/tanstack/notifications/namespaces.ts index f4c7fcba6..f7d1154f5 100644 --- a/packages/vitnode/src/tanstack/notifications/namespaces.ts +++ b/packages/vitnode/src/tanstack/notifications/namespaces.ts @@ -1,7 +1,3 @@ -/** - * Message namespaces of the Notifications Center page. Their own module - * because `routes.tsx` is read by the build in Node. - */ export const NOTIFICATIONS_NAMESPACES = [ "core.global", "core.notifications", diff --git a/packages/vitnode/src/tanstack/notifications/route-search.ts b/packages/vitnode/src/tanstack/notifications/route-search.ts index 0bd7bc521..9e8e21ca1 100644 --- a/packages/vitnode/src/tanstack/notifications/route-search.ts +++ b/packages/vitnode/src/tanstack/notifications/route-search.ts @@ -5,7 +5,6 @@ export interface NotificationsRouteSearch { const CATEGORY = /^[a-z0-9_-]{1,50}$/; -/** Only the two filters the page understands survive; anything else drops. */ export const normalizeNotificationsRouteSearch = ( input: Record<string, unknown>, ): NotificationsRouteSearch => ({ diff --git a/packages/vitnode/src/tanstack/notifications/screen.tsx b/packages/vitnode/src/tanstack/notifications/screen.tsx index b3b39e7c4..9b1d03471 100644 --- a/packages/vitnode/src/tanstack/notifications/screen.tsx +++ b/packages/vitnode/src/tanstack/notifications/screen.tsx @@ -38,6 +38,11 @@ import { NOTIFICATIONS_NAMESPACES } from "./namespaces"; const ALL = "all"; +type NotificationsScreenProps = NotificationsRouteData & { + navigate: (options: { search: NotificationsRouteSearch }) => unknown; + search: NotificationsRouteSearch; +}; + const NotificationsList = ({ search, userId, @@ -133,17 +138,13 @@ const NotificationsList = ({ ); }; -/** The full Notifications Center. Viewing it never marks anything read. */ export const NotificationsRouteContent = ({ description, navigate, search, title, userId, -}: NotificationsRouteData & { - navigate: (options: { search: NotificationsRouteSearch }) => unknown; - search: NotificationsRouteSearch; -}) => ( +}: NotificationsScreenProps) => ( <RouteMessages namespaces={NOTIFICATIONS_NAMESPACES}> <NotificationsScreen description={description} @@ -161,10 +162,7 @@ const NotificationsScreen = ({ search, title, userId, -}: NotificationsRouteData & { - navigate: (options: { search: NotificationsRouteSearch }) => unknown; - search: NotificationsRouteSearch; -}) => { +}: NotificationsScreenProps) => { const t = useTranslations("core.notifications"); const actions = useNotificationActions(userId); const state = useNotificationState(userId); diff --git a/packages/vitnode/src/tanstack/notifications/settings-screen.tsx b/packages/vitnode/src/tanstack/notifications/settings-screen.tsx index 453e9ac8d..eea7219aa 100644 --- a/packages/vitnode/src/tanstack/notifications/settings-screen.tsx +++ b/packages/vitnode/src/tanstack/notifications/settings-screen.tsx @@ -3,6 +3,7 @@ import React from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; +import type { NotificationEmailMode } from "@/lib/notifications/types"; import type { NotificationPreferencesView, NotificationPreferenceTypeView, @@ -25,7 +26,6 @@ import { NotificationTypeItem } from "@/views/notifications/settings/type-item"; import { sessionQueryOptions } from "../auth/session-query"; -type EmailMode = NotificationPreferenceTypeView["value"]["email"]; type UpdateBody = Parameters<typeof updateNotificationPreferencesInBrowser>[0]; const usePreferenceSaver = (userId: number) => { @@ -81,9 +81,9 @@ export const NotificationSettingsPanel = ({ userId }: { userId: number }) => { const { data: session } = useSuspenseQuery(sessionQueryOptions()); const save = usePreferenceSaver(userId); const [openId, setOpenId] = React.useState<null | string>(null); - const [emailBeforeStop, setEmailBeforeStop] = React.useState<null | Record< + const emailBeforeStopRef = React.useRef<null | Record< string, - EmailMode + NotificationEmailMode >>(null); const groups = [ @@ -102,18 +102,19 @@ export const NotificationSettingsPanel = ({ userId }: { userId: number }) => { 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"), ); - setEmailBeforeStop(null); + emailBeforeStopRef.current = null; return; } - setEmailBeforeStop(emailSnapshot(data.types)); + emailBeforeStopRef.current = emailSnapshot(data.types); void save( { types: emailOffPatch(data.types) }, t("channels.email.stopped_toast_desc"), diff --git a/packages/vitnode/src/tanstack/notifications/state-sync.test.tsx b/packages/vitnode/src/tanstack/notifications/state-sync.test.tsx index 6cbff7732..66855935f 100644 --- a/packages/vitnode/src/tanstack/notifications/state-sync.test.tsx +++ b/packages/vitnode/src/tanstack/notifications/state-sync.test.tsx @@ -13,6 +13,7 @@ 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) { @@ -116,7 +117,6 @@ describe("NotificationStateSync", () => { act(() => sockets[0]?.push({ reason: "read", revision: 7, unread: 3 })); expect(screen.getByRole("status").textContent).toBe("4@8"); - // A session request sent before those updates resolves late: ignored. act(() => { queryClient.setQueryData(SESSION_QUERY_KEY, { user: { id: USER_ID, notifications: { revision: 6, unread: 2 } }, @@ -135,7 +135,6 @@ describe("NotificationStateSync", () => { act(() => sockets[0]?.open()); await waitFor(() => expect(fetchState).toHaveBeenCalledTimes(1)); - // Offline for a while: the server moved on without us. act(() => sockets[0]?.drop()); await waitFor(() => expect(sockets).toHaveLength(2), { timeout: 4_000 }); act(() => sockets[1]?.open()); @@ -147,8 +146,7 @@ describe("NotificationStateSync", () => { }); it("polls the count every minute when there is no socket to listen on", () => { - // Same origin for web and API: this deployment opens no WebSocket. - vi.stubEnv("VITNODE_API_URL", "http://localhost:3000"); + vi.stubEnv("VITNODE_API_URL", SAME_ORIGIN_API_URL); vi.useFakeTimers(); const fetchState = vi.fn( async () => await Promise.resolve({ revision: 9, unread: 3 }), diff --git a/packages/vitnode/src/tanstack/notifications/state-sync.tsx b/packages/vitnode/src/tanstack/notifications/state-sync.tsx index 7abb9af51..bb74090ef 100644 --- a/packages/vitnode/src/tanstack/notifications/state-sync.tsx +++ b/packages/vitnode/src/tanstack/notifications/state-sync.tsx @@ -12,29 +12,13 @@ import { useVitNodeWebSocket } from "@/ws/use-websocket"; import { useSessionQuery } from "../auth/session-query"; import { invalidateNotificationLists } from "./cache"; -/** How long a tab may sit in the background before it re-reads the count. */ const STALE_AFTER_HIDDEN_MS = 30_000; -/** - * Without a socket - none configured, or it is down - the count is re-read at - * this pace while the tab is visible. One primary-key read per tab. - */ const POLL_WITHOUT_SOCKET_MS = 60_000; -const OPEN = 1; - -/** - * Keeps the unread count in step for the signed-in user: - * - * - seeds it from the session payload; - * - applies every realtime state message, newest revision wins; - * - re-reads it when the socket (re)connects - messages sent while offline - * are not replayed - and when a long-hidden tab comes back; - * - without a socket, re-reads it every minute while the tab is visible; - * - refreshes open notification lists once per burst, not once per message. - */ +const SOCKET_OPEN = 1; + export const NotificationStateSync = ({ fetchState = fetchNotificationState, }: { - /** How the authoritative state is re-read. Replaceable for tests. */ fetchState?: () => Promise<{ revision: number; unread: number }>; } = {}) => { const queryClient = useQueryClient(); @@ -58,7 +42,6 @@ export const NotificationStateSync = ({ } }, [sessionState, userId]); - // Created in an effect, not during render: it closes over refs. const coalescerRef = React.useRef<null | ReturnType<typeof createCoalescer>>( null, ); @@ -80,17 +63,13 @@ export const NotificationStateSync = ({ }; }, [queryClient]); - const refresh = React.useCallback(async () => { - const current = userIdRef.current; - if (current === null) return; - try { - if (notificationStateStore.apply(current, await fetchState())) { - coalescerRef.current?.schedule(); - } - } catch { - // Offline or signed out; the next successful read corrects it. + const refresh = React.useEffectEvent(async () => { + if (userId === null) return; + const latest = await fetchState().catch(() => null); + if (latest && notificationStateStore.apply(userId, latest)) { + coalescerRef.current?.schedule(); } - }, [fetchState]); + }); const { readyState } = useVitNodeWebSocket(notificationsStateChannel, { onMessage: (message: NotificationStateMessage) => { @@ -103,18 +82,18 @@ export const NotificationStateSync = ({ }); React.useEffect(() => { - if (readyState === OPEN) void refresh(); - }, [readyState, refresh]); + if (readyState === SOCKET_OPEN) void refresh(); + }, [readyState]); React.useEffect(() => { - if (readyState === OPEN || userId === null) return; + if (readyState === SOCKET_OPEN || userId === null) return; const timer = setInterval(() => { if (document.visibilityState === "visible") void refresh(); }, POLL_WITHOUT_SOCKET_MS); return () => clearInterval(timer); - }, [readyState, refresh, userId]); + }, [readyState, userId]); React.useEffect(() => { let hiddenAt: null | number = null; @@ -129,7 +108,7 @@ export const NotificationStateSync = ({ document.addEventListener("visibilitychange", onVisibility); return () => document.removeEventListener("visibilitychange", onVisibility); - }, [refresh]); + }, []); return null; }; diff --git a/packages/vitnode/src/tests/notifications.ts b/packages/vitnode/src/tests/notifications.ts index d97de9615..6bbe569df 100644 --- a/packages/vitnode/src/tests/notifications.ts +++ b/packages/vitnode/src/tests/notifications.ts @@ -48,12 +48,9 @@ export interface NotificationsHarness { c: Context<EnvVitNode>; createUsers: (count: number, language?: string) => Promise<number[]>; database: TestDatabaseHandle; - /** Runs queued tasks until nothing due is left. */ drainQueue: (now?: Date) => Promise<number>; emitted: { name: string; payload: unknown }[]; - /** A context on its own connection pool, for concurrent work. */ forkContext: () => Context<EnvVitNode>; - /** Queries sent through any connection since the harness was created. */ queryCount: () => number; realtime: RealtimeRecord[]; sentEmails: SentEmailRecord[]; @@ -97,9 +94,7 @@ export const createNotificationsHarness = async ({ await database.db .insert(core_languages) .values({ code: "en", name: "English", timezone: "UTC" }); - await database.db - .insert(core_roles) - .values({ id: 1, name: "Member", root: false } as never); + await database.db.insert(core_roles).values({ id: 1 }); const realtime: RealtimeRecord[] = []; const emitted: NotificationsHarness["emitted"] = []; diff --git a/packages/vitnode/src/tests/postgres.ts b/packages/vitnode/src/tests/postgres.ts index 0c3528b1c..ee5e81296 100644 --- a/packages/vitnode/src/tests/postgres.ts +++ b/packages/vitnode/src/tests/postgres.ts @@ -6,30 +6,18 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import { describe } from "vitest"; -/** - * Integration tests that need real Postgres semantics - row locks, `ON - * CONFLICT`, concurrent transactions - run against the server named here and - * are skipped without it: - * - * VITNODE_TEST_POSTGRES_URL=postgresql://root@localhost:5432/postgres pnpm test - * - * Each `createTestDatabase` call makes a throwaway database, so test files can - * run in parallel and never see each other's rows. - */ -export const TEST_POSTGRES_URL = process.env.VITNODE_TEST_POSTGRES_URL; +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); }; -export type TestDatabase = ReturnType<typeof drizzle>; +type TestDatabase = ReturnType<typeof drizzle>; export interface TestDatabaseHandle { - /** A second, independent connection pool - for genuinely concurrent work. */ connect: () => TestDatabase; db: TestDatabase; drop: () => Promise<void>; - url: string; } const withDatabase = (url: string, database: string): string => { @@ -39,7 +27,6 @@ const withDatabase = (url: string, database: string): string => { return parsed.toString(); }; -/** Creates a database holding exactly the tables in `schema`. */ export const createTestDatabase = async ( schema: Record<string, unknown>, { onQuery }: { onQuery?: () => void } = {}, @@ -93,6 +80,5 @@ export const createTestDatabase = async ( await cleanup.unsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`); await cleanup.end(); }, - url, }; }; 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 index ba4c546ba..9dafca471 100644 --- 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 @@ -15,6 +15,12 @@ const STATE_ICON = { 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 }) => ( <a className="text-primary underline-offset-4 hover:underline" @@ -39,13 +45,71 @@ const Fact = ({ </div> ); +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: () => <DateFormat date={lastRun} showFullDate />, + })} + </> + ); +}; + +const StatusHelp = ({ health }: { health: CronHealth }) => { + const t = useTranslations("admin.advanced.cron.health"); + + return ( + <p className="text-sm leading-relaxed text-pretty"> + {health.secretRejected ? ( + <>{t("secret_rejected")} </> + ) : ( + !health.active && <>{t("no_adapter")} </> + )} + {t.rich("stale.help", { + docs: text => <DocsLink>{text}</DocsLink>, + })} + </p> + ); +}; + export const CronSchedulerStatus = ({ health }: { health: CronHealth }) => { const t = useTranslations("admin.advanced.cron.health"); - const state: SchedulerState = health.stale - ? "stopped" - : health.overdueJobs > 0 - ? "overdue" - : "ok"; + const state = schedulerStateOf(health); const Icon = STATE_ICON[state]; const lastRun = health.lastRun; @@ -59,9 +123,7 @@ export const CronSchedulerStatus = ({ health }: { health: CronHealth }) => { <span className={cn( "flex size-10 shrink-0 items-center justify-center rounded-full [&_svg]:size-5", - state === "stopped" && "bg-destructive/10 text-destructive", - state === "overdue" && "bg-warn/10 text-warn", - state === "ok" && "bg-success/10 text-success", + STATE_TONE[state], )} > <Icon aria-hidden /> @@ -72,36 +134,15 @@ export const CronSchedulerStatus = ({ health }: { health: CronHealth }) => { className="text-lg font-semibold text-balance" id="cron-scheduler-status" > - {state === "stopped" - ? t("stale.title") - : state === "overdue" - ? t("overdue.title", { count: health.overdueJobs }) - : t("ok.title")} + <StatusTitle health={health} state={state} /> </h2> <p className="text-muted-foreground max-w-prose text-sm leading-relaxed text-pretty"> - {state === "stopped" - ? lastRun - ? t.rich("stale.desc", { - date: () => <DateFormat date={lastRun} showFullDate />, - }) - : t("stale.desc_never") - : state === "overdue" - ? t("overdue.desc") - : t("ok.desc")} + <StatusDescription lastRun={lastRun} state={state} /> </p> {(state === "stopped" || health.secretRejected) && ( - <p className="text-sm leading-relaxed text-pretty"> - {health.secretRejected ? ( - <>{t("secret_rejected")} </> - ) : ( - !health.active && <>{t("no_adapter")} </> - )} - {t.rich("stale.help", { - docs: text => <DocsLink>{text}</DocsLink>, - })} - </p> + <StatusHelp health={health} /> )} </div> </div> 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 a574a84b3..9913e5173 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 @@ -67,11 +67,9 @@ export const CronTableContent = ({ )} /> <span - className={cn( - status === "overdue" - ? "text-warn" - : "text-muted-foreground", - )} + className={ + status === "overdue" ? "text-warn" : "text-muted-foreground" + } > {t(`list.status.${status}`)} </span> 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 a7afc9def..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 @@ -26,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 ( + <span className="text-destructive"> + {lastRun + ? t.rich("stale", { date: () => <DateFormat date={lastRun} /> }) + : t("stale_never")} + </span> + ); + } + if (failing) { + return ( + <span className="text-amber-700 dark:text-amber-400"> + {t("overdue", { count: cron.overdueJobs })} + </span> + ); + } + if (!cron.active) return <span>{t("not_configured")}</span>; + if (!cron.secure) { + return ( + <span className="text-amber-700 dark:text-amber-400"> + {t("insecure")} + </span> + ); + } + + return <span>{t("jobs", { count: cron.jobs })}</span>; +}; + +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 ( + <IntegrationCard + description={t("cron.desc")} + href={DOCS_URLS.cron} + Icon={ClockIcon} + meta={<CronMeta cron={cron} failing={failing} />} + 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, @@ -42,27 +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 cronHasRun = data.cron.active || data.cron.lastRun !== null; - const cronFailing = cronHasRun && data.cron.overdueJobs > 0; - const cronStatus: IntegrationStatus = cronFailing - ? "warning" - : !data.cron.active - ? "inactive" - : !data.cron.secure - ? "warning" - : "active"; - const cronLastRun = data.cron.lastRun; - return ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <IntegrationCard @@ -142,38 +203,7 @@ export const IntegrationsContent = ({ title={t("storage.title")} /> - <IntegrationCard - description={t("cron.desc")} - href={DOCS_URLS.cron} - Icon={ClockIcon} - meta={ - cronFailing && data.cron.stale ? ( - <span className="text-destructive"> - {cronLastRun - ? t.rich("cron.stale", { - date: () => <DateFormat date={cronLastRun} />, - }) - : t("cron.stale_never")} - </span> - ) : cronFailing ? ( - <span className="text-amber-700 dark:text-amber-400"> - {t("cron.overdue", { count: data.cron.overdueJobs })} - </span> - ) : !data.cron.active ? ( - <span>{t("cron.not_configured")}</span> - ) : !data.cron.secure ? ( - <span className="text-amber-700 dark:text-amber-400"> - {t("cron.insecure")} - </span> - ) : ( - <span>{t("cron.jobs", { count: data.cron.jobs })}</span> - ) - } - readMoreLabel={t("read_more")} - status={cronStatus} - statusLabel={statusLabel(cronStatus)} - title={t("cron.title")} - /> + <CronIntegrationCard cron={data.cron} /> <IntegrationCard description={t("content_preview.desc")} diff --git a/packages/vitnode/src/views/admin/views/core/system/notifications/activity-content.tsx b/packages/vitnode/src/views/admin/views/core/system/notifications/activity-content.tsx index 8a3a4a999..cb12684e0 100644 --- a/packages/vitnode/src/views/admin/views/core/system/notifications/activity-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/notifications/activity-content.tsx @@ -79,9 +79,11 @@ const SWAP_EXIT = { y: -4, } satisfies TargetAndTransition; +const SNAPPY_EASE = [0.32, 0.72, 0, 1] as const; + const SWAP_TRANSITION = { duration: 0.2, - ease: [0.32, 0.72, 0, 1], + ease: SNAPPY_EASE, } satisfies Transition; const Swap = ({ @@ -181,8 +183,6 @@ const SparkTooltip = ({ ); }; -const TOOLTIP_EASE = [0.32, 0.72, 0, 1] as const; - const SparkTooltipLayer = ({ children, glide, @@ -228,10 +228,10 @@ const SparkTooltipLayer = ({ ? { duration: 0 } : { default: open - ? { duration: 0.15, ease: TOOLTIP_EASE } + ? { duration: 0.15, ease: SNAPPY_EASE } : { duration: 0.1, ease: "easeIn" }, x: glide - ? { duration: 0.14, ease: TOOLTIP_EASE } + ? { duration: 0.14, ease: SNAPPY_EASE } : { duration: 0 }, } } @@ -434,6 +434,7 @@ const StatTile = ({ }) => { const format = useFormatter(); const current = valueOf(stats.totals, metric); + const previous = valueOf(stats.previous, metric); const value = metric === "failureRate" ? format.number(current / 100, { @@ -449,11 +450,11 @@ const StatTile = ({ <p className="text-2xl font-semibold tracking-tight tabular-nums"> <Swap swapKey={value}>{value}</Swap> </p> - <Swap swapKey={`${current}:${valueOf(stats.previous, metric)}`}> + <Swap swapKey={`${current}:${previous}`}> <Delta current={current} goodWhenUp={goodWhenUp} - previous={valueOf(stats.previous, metric)} + previous={previous} /> </Swap> <Sparkline 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 index ac826d5a3..30c911e73 100644 --- 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 @@ -3,7 +3,6 @@ import type { NotificationEmailMode } from "@/lib/notifications/types"; import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; -/** What every write on this screen resolves to - never a throw. */ export type NotificationsMutationResult<T> = | { data: T; error?: never; status?: never } | { data?: never; error: string; status: number }; @@ -18,7 +17,6 @@ export interface NotificationTypePolicyPatch { memberCanEdit?: boolean; } -/** The writes the screen can perform, as the host hands them in. */ export interface NotificationsAdminActions { cancelQueuedEmails: () => Promise< NotificationsMutationResult<{ cancelled: number }> @@ -52,10 +50,6 @@ const failure = async (response: { text: () => Promise<string>; }) => ({ error: await response.text(), status: response.status }); -const options = { credentials: "include" } as const; - -// Each call catches: the client throws on a 500 with the server's own text, -// which the server has already logged. The screen only needs to know it failed. export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { cancelQueuedEmails: async () => { try { @@ -63,7 +57,6 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { plugin: CONFIG_PLUGIN.pluginId, method: "post", module: "admin/notifications", - options, path: "/emails/cancel", }); if (!response.ok) return await failure(response); @@ -79,7 +72,6 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { plugin: CONFIG_PLUGIN.pluginId, method: "post", module: "admin/notifications", - options, path: "/delete-all", }); if (!response.ok) return await failure(response); @@ -95,7 +87,6 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { plugin: CONFIG_PLUGIN.pluginId, method: "post", module: "admin/notifications", - options, path: "/read-all", }); if (!response.ok) return await failure(response); @@ -111,7 +102,6 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { plugin: CONFIG_PLUGIN.pluginId, method: "post", module: "admin/notifications", - options, path: "/pause", }); if (!response.ok) return await failure(response); @@ -127,7 +117,6 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { plugin: CONFIG_PLUGIN.pluginId, method: "post", module: "admin/notifications", - options, path: "/members/reset-preferences", }); if (!response.ok) return await failure(response); @@ -143,7 +132,6 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { plugin: CONFIG_PLUGIN.pluginId, method: "post", module: "admin/notifications", - options, path: "/resume", }); if (!response.ok) return await failure(response); @@ -159,7 +147,6 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { plugin: CONFIG_PLUGIN.pluginId, method: "post", module: "admin/notifications", - options, path: "/test-email", }); if (!response.ok) return await failure(response); @@ -176,7 +163,6 @@ export const notificationsAdminActionsInBrowser: NotificationsAdminActions = { args: { body, params: { type } }, method: "put", module: "admin/notifications", - options, path: "/types/{type}", }); if (!response.ok) return await failure(response); 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 index 6bdc62f2e..435b96c4c 100644 --- 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 @@ -124,7 +124,6 @@ export const fetchNotificationStats = async ({ return await response.json(); }; -/** The root every cache entry of the notifications screen hangs off. */ export const notificationsAdminQueryRoot = adminQueryRoot("notifications"); export const notificationsOverviewQueryKey = [ @@ -142,7 +141,6 @@ export const notificationsOverviewQueryOptions = () => queryFn: async () => await fetchNotificationsOverview(), queryKey: notificationsOverviewQueryKey, retry: false, - /** {@link OPERATIONAL_STALE_TIME} - Health counts move while nobody touches anything. */ staleTime: OPERATIONAL_STALE_TIME, }); 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 index 0f09418f2..8a6bae39a 100644 --- 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 @@ -145,7 +145,7 @@ const DangerRow = ({ <li> {confirm( <button - className="focus-visible:ring-ring/50 [@media(hover:hover)]:hover:bg-muted/50 flex min-h-12 w-full items-center gap-3 px-4 py-3 text-start outline-none focus-visible:ring-[3px] focus-visible:ring-inset" + className="focus-visible:ring-ring/50 [@media(hover:hover)]:hover:bg-muted/50 flex min-h-12 w-full items-center gap-3 px-4 py-3 text-start outline-none focus-visible:ring-3 focus-visible:ring-inset" type="button" > <span 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 index bf20e5d1e..cd17ce122 100644 --- 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 @@ -8,11 +8,11 @@ import { MailIcon, SmartphoneIcon, } from "lucide-react"; -import { AnimatePresence, motion, useReducedMotion } from "motion/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 { @@ -25,12 +25,6 @@ import { import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Switch } from "@/components/ui/switch"; -import { - REVEAL_EXIT_TRANSITION, - REVEAL_HIDDEN, - REVEAL_SHOWN, - REVEAL_TRANSITION, -} from "@/lib/motion"; import type { NotificationsAdminActions, @@ -135,6 +129,95 @@ const ChoiceGroup = <V extends string>({ ); }; +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 ( + <span className="flex flex-wrap gap-1"> + {lockBadge ? ( + <Badge variant="outline"> + <LockIcon aria-hidden /> + {lockBadge} + </Badge> + ) : null} + {sentNowhere ? ( + <Badge variant="secondary">{t("badges.nowhere")}</Badge> + ) : null} + {type.grouped ? ( + <Badge variant="secondary">{t("badges.grouped")}</Badge> + ) : null} + {missingEmailTemplate ? ( + <Badge variant="warning">{t("badges.no_template")}</Badge> + ) : null} + </span> + ); +}; + +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 ( + <ul + aria-label={t("summary.label", { type: type.label })} + className="flex flex-wrap gap-1.5" + > + <SummaryChip + Icon={ListIcon} + label={t("summary.list")} + muted={choices.list === "disabled"} + value={short[choices.list]} + /> + <SummaryChip + Icon={SmartphoneIcon} + label={t("summary.push")} + muted={choices.push === "disabled"} + value={short[choices.push]} + /> + <SummaryChip + Icon={MailIcon} + label={t("summary.email")} + muted={choices.email === null || choices.email === "disabled"} + value={ + choices.email === null + ? t("summary.unsupported") + : short[choices.email] + } + /> + </ul> + ); +}; + const TypeItem = ({ canEdit, isOpen, @@ -152,10 +235,12 @@ const TypeItem = ({ const tError = useTranslations("core.global.errors"); const panelId = React.useId(); const memberId = React.useId(); - const shouldReduceMotion = useReducedMotion(); const [, startTransition] = React.useTransition(); const [type, applyOptimistic] = React.useOptimistic(initial, applyPatch); - const choices: TypeChoices = choicesOf(type); + const choices = choicesOf(type); + const sentNowhere = isSentNowhere(choices); + const missingEmailTemplate = + type.emailSupported && !type.emailAvailable && choices.email !== "disabled"; const locked = !canEdit; const save = (patch: NotificationTypePolicyPatch) => { @@ -177,12 +262,6 @@ const TypeItem = ({ }); }; - const short = { - available: t("summary.available"), - default_off: t("summary.off"), - default_on: t("summary.on"), - disabled: t("summary.disabled"), - }; const listOptions = LIST_CHOICES.map(value => ({ hint: t(`list.${value}.hint`), label: t(`list.${value}.label`), @@ -201,12 +280,12 @@ const TypeItem = ({ return ( <li className="border-b last:border-b-0"> - <div className="has-focus-visible:ring-ring/50 [@media(hover:hover)]:hover:bg-muted/40 relative flex flex-col gap-3 px-4 py-4 has-focus-visible:ring-[3px] has-focus-visible:ring-inset sm:px-6 md:flex-row md:items-center md:justify-between"> + <div className="has-focus-visible:ring-ring/50 [@media(hover:hover)]:hover:bg-muted/40 relative flex flex-col gap-3 px-4 py-4 has-focus-visible:ring-3 has-focus-visible:ring-inset sm:px-6 md:flex-row md:items-center md:justify-between"> <div className="flex min-w-0 flex-col gap-1"> <h4 className={cn( "text-sm leading-snug font-medium text-pretty break-words", - isSentNowhere(choices) && "text-muted-foreground", + sentNowhere && "text-muted-foreground", )} > <button @@ -222,67 +301,15 @@ const TypeItem = ({ <span className="text-muted-foreground font-mono text-xs break-all"> {type.id} </span> - {type.mandatory || - !choices.memberCanEdit || - isSentNowhere(choices) || - type.grouped || - (type.emailSupported && - !type.emailAvailable && - choices.email !== "disabled") ? ( - <span className="flex flex-wrap gap-1"> - {type.mandatory ? ( - <Badge variant="outline"> - <LockIcon aria-hidden /> - {t("badges.mandatory")} - </Badge> - ) : !choices.memberCanEdit ? ( - <Badge variant="outline"> - <LockIcon aria-hidden /> - {t("badges.locked")} - </Badge> - ) : null} - {isSentNowhere(choices) ? ( - <Badge variant="secondary">{t("badges.nowhere")}</Badge> - ) : null} - {type.grouped ? ( - <Badge variant="secondary">{t("badges.grouped")}</Badge> - ) : null} - {type.emailSupported && - !type.emailAvailable && - choices.email !== "disabled" ? ( - <Badge variant="warning">{t("badges.no_template")}</Badge> - ) : null} - </span> - ) : null} + <TypeBadges + choices={choices} + missingEmailTemplate={missingEmailTemplate} + sentNowhere={sentNowhere} + type={type} + /> </div> <div className="flex items-center justify-between gap-3 md:justify-end"> - <ul - aria-label={t("summary.label", { type: type.label })} - className="flex flex-wrap gap-1.5" - > - <SummaryChip - Icon={ListIcon} - label={t("summary.list")} - muted={choices.list === "disabled"} - value={short[choices.list]} - /> - <SummaryChip - Icon={SmartphoneIcon} - label={t("summary.push")} - muted={choices.push === "disabled"} - value={short[choices.push]} - /> - <SummaryChip - Icon={MailIcon} - label={t("summary.email")} - muted={choices.email === null || choices.email === "disabled"} - value={ - choices.email === null - ? t("summary.unsupported") - : short[choices.email] - } - /> - </ul> + <TypeSummaryChips choices={choices} type={type} /> <ChevronDownIcon aria-hidden className={cn( @@ -293,103 +320,87 @@ const TypeItem = ({ </div> </div> - <AnimatePresence initial={false}> - {isOpen ? ( - <motion.div - animate={REVEAL_SHOWN} - className="overflow-y-clip" - exit={ - shouldReduceMotion - ? undefined - : { ...REVEAL_HIDDEN, transition: REVEAL_EXIT_TRANSITION } - } - id={panelId} - initial={shouldReduceMotion ? false : REVEAL_HIDDEN} - key="panel" - transition={REVEAL_TRANSITION} - > - <div className="bg-muted/30 flex flex-col gap-6 border-t px-4 py-5 sm:px-6"> - <div className="flex items-start justify-between gap-4"> - <Label - className="flex flex-col items-start gap-1 font-normal" - htmlFor={memberId} - > - <span className="flex items-center gap-2 text-sm font-medium"> - <LockIcon - aria-hidden - className="text-muted-foreground size-4" - /> - {t("member_can_edit.label")} - </span> - <span className="text-muted-foreground text-xs leading-relaxed text-pretty"> - {type.mandatory - ? t("member_can_edit.mandatory") - : t("member_can_edit.desc")} - </span> - </Label> - <Switch - checked={choices.memberCanEdit} - disabled={locked || type.mandatory} - id={memberId} - onCheckedChange={memberCanEdit => { - save({ memberCanEdit }); - }} + <RevealPanel className="overflow-y-clip" id={panelId} open={isOpen}> + <div className="bg-muted/30 flex flex-col gap-6 border-t px-4 py-5 sm:px-6"> + <div className="flex items-start justify-between gap-4"> + <Label + className="flex flex-col items-start gap-1 font-normal" + htmlFor={memberId} + > + <span className="flex items-center gap-2 text-sm font-medium"> + <LockIcon + aria-hidden + className="text-muted-foreground size-4" /> - </div> + {t("member_can_edit.label")} + </span> + <span className="text-muted-foreground text-xs leading-relaxed text-pretty"> + {type.mandatory + ? t("member_can_edit.mandatory") + : t("member_can_edit.desc")} + </span> + </Label> + <Switch + checked={choices.memberCanEdit} + disabled={locked || type.mandatory} + id={memberId} + onCheckedChange={memberCanEdit => { + save({ memberCanEdit }); + }} + /> + </div> - <div className="grid gap-6 lg:grid-cols-3"> - <ChoiceGroup - disabled={locked || type.mandatory} - Icon={ListIcon} - legend={t("list.legend")} - note={type.mandatory ? t("list.mandatory") : undefined} - onChange={choice => { - save(listPatch(choice)); - }} - options={listOptions} - value={choices.list} - /> - <ChoiceGroup - disabled={locked} - Icon={SmartphoneIcon} - legend={t("push.legend")} - note={t("push.note")} - onChange={choice => { - save(pushPatch(choice)); - }} - options={pushOptions} - value={choices.push} - /> - {choices.email === null ? ( - <div className="flex min-w-0 flex-col"> - <p className="mb-3 flex items-center gap-2 text-sm font-medium"> - <MailIcon - aria-hidden - className="text-muted-foreground size-4" - /> - {t("email.legend")} - </p> - <p className="text-muted-foreground rounded-lg border border-dashed p-3 text-sm leading-relaxed"> - {t("email.unsupported")} - </p> - </div> - ) : ( - <ChoiceGroup - disabled={locked} - Icon={MailIcon} - legend={t("email.legend")} - onChange={choice => { - save(emailPatch(choice, type)); - }} - options={emailOptions} - value={choices.email} + <div className="grid gap-6 lg:grid-cols-3"> + <ChoiceGroup + disabled={locked || type.mandatory} + Icon={ListIcon} + legend={t("list.legend")} + note={type.mandatory ? t("list.mandatory") : undefined} + onChange={choice => { + save(listPatch(choice)); + }} + options={listOptions} + value={choices.list} + /> + <ChoiceGroup + disabled={locked} + Icon={SmartphoneIcon} + legend={t("push.legend")} + note={t("push.note")} + onChange={choice => { + save(pushPatch(choice)); + }} + options={pushOptions} + value={choices.push} + /> + {choices.email === null ? ( + <div className="flex min-w-0 flex-col"> + <p className="mb-3 flex items-center gap-2 text-sm font-medium"> + <MailIcon + aria-hidden + className="text-muted-foreground size-4" /> - )} + {t("email.legend")} + </p> + <p className="text-muted-foreground rounded-lg border border-dashed p-3 text-sm leading-relaxed"> + {t("email.unsupported")} + </p> </div> - </div> - </motion.div> - ) : null} - </AnimatePresence> + ) : ( + <ChoiceGroup + disabled={locked} + Icon={MailIcon} + legend={t("email.legend")} + onChange={choice => { + save(emailPatch(choice, type)); + }} + options={emailOptions} + value={choices.email} + /> + )} + </div> + </div> + </RevealPanel> </li> ); }; 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 index b20237374..6150f444b 100644 --- a/packages/vitnode/src/views/auth/settings/overview/time-zone-editor.tsx +++ b/packages/vitnode/src/views/auth/settings/overview/time-zone-editor.tsx @@ -11,7 +11,11 @@ import { Button } from "@/components/ui/button"; import type { UpdateTimeZone } from "./time-zone-update"; -import { deviceTimeZone, supportedTimeZones } from "./time-zone-update"; +import { + deviceTimeZone, + supportedTimeZones, + timeZoneLabel, +} from "./time-zone-update"; export const TimeZoneEditor = ({ onClose, @@ -42,7 +46,7 @@ export const TimeZoneEditor = ({ toast.success(t("timeZoneSaved"), { description: timeZone - ? t("timeZoneSavedDesc", { timeZone: timeZone.replace(/_/g, " ") }) + ? t("timeZoneSavedDesc", { timeZone: timeZoneLabel(timeZone) }) : t("timeZoneAuto"), }); onClose(); @@ -91,9 +95,7 @@ export const TimeZoneEditor = ({ type="button" variant="link" > - {t("timeZoneUseDevice", { - timeZone: device.replace(/_/g, " "), - })} + {t("timeZoneUseDevice", { timeZone: timeZoneLabel(device) })} </Button> ) : ( <span /> 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 index 052f72e22..24875a73f 100644 --- a/packages/vitnode/src/views/auth/settings/overview/time-zone-row.tsx +++ b/packages/vitnode/src/views/auth/settings/overview/time-zone-row.tsx @@ -11,6 +11,7 @@ import { SETTINGS_ROW_LABEL, SettingsGroup, } from "../settings-group"; +import { timeZoneLabel } from "./time-zone-update"; const TimeZoneEditor = React.lazy(async () => import("./time-zone-editor").then(module => ({ @@ -66,7 +67,7 @@ export const TimeZoneGroup = ({ > <span className={SETTINGS_ROW_LABEL}>{t("timeZone")}</span> <span className="text-muted-foreground min-w-0 flex-1 truncate text-end text-sm"> - {timeZone ? timeZone.replace(/_/g, " ") : t("timeZoneAuto")} + {timeZone ? timeZoneLabel(timeZone) : t("timeZoneAuto")} </span> <span className="sr-only">{t("edit")}</span> <ChevronRightIcon 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 index 75c44a58e..7f7fd0ffc 100644 --- a/packages/vitnode/src/views/auth/settings/overview/time-zone-update.ts +++ b/packages/vitnode/src/views/auth/settings/overview/time-zone-update.ts @@ -17,7 +17,6 @@ export const updateTimeZoneInBrowser: UpdateTimeZone = async timeZone => { args: { body: { timeZone } }, method: "put", module: "users", - options: { credentials: "include" }, path: "/me/time-zone", }); @@ -29,6 +28,9 @@ export const updateTimeZoneInBrowser: UpdateTimeZone = async timeZone => { } }; +export const timeZoneLabel = (timeZone: string): string => + timeZone.replace(/_/g, " "); + export const supportedTimeZones = (): string[] => { try { const zones = Intl.supportedValuesOf("timeZone"); 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 93379890c..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,7 +14,6 @@ export interface HeaderLayoutContentProps extends Omit< mobileUser?: React.ReactNode; moreNavigationLabel: string; navigation: HeaderNavItem[]; - /** Shown on every screen size, before the user menu. */ notifications?: React.ReactNode; user?: React.ReactNode; } diff --git a/packages/vitnode/src/views/notifications/coalesce.ts b/packages/vitnode/src/views/notifications/coalesce.ts index 27cdbe879..3bcec6f53 100644 --- a/packages/vitnode/src/views/notifications/coalesce.ts +++ b/packages/vitnode/src/views/notifications/coalesce.ts @@ -1,8 +1,3 @@ -/** - * Collapses a burst of calls into one: it fires `wait` ms after the last call, - * but never later than `maxWait` ms after the first, so a steady stream of - * notifications still refreshes the list instead of postponing it forever. - */ export const createCoalescer = ( run: () => void, { maxWait, wait }: { maxWait: number; wait: number }, diff --git a/packages/vitnode/src/views/notifications/notification-item.tsx b/packages/vitnode/src/views/notifications/notification-item.tsx index cf3f2d916..b279d6d35 100644 --- a/packages/vitnode/src/views/notifications/notification-item.tsx +++ b/packages/vitnode/src/views/notifications/notification-item.tsx @@ -11,11 +11,6 @@ import type { NotificationItemView } from "./notifications-query"; export interface NotificationItemProps { item: NotificationItemView; onArchive?: (item: NotificationItemView) => void; - /** - * Called on activation. It marks the item read and navigates; for a - * modified click (new tab) the browser follows the link itself and this - * only marks it read. - */ onOpen: (item: NotificationItemView, options: { newTab: boolean }) => void; onToggleRead?: (item: NotificationItemView) => void; variant?: "compact" | "full"; @@ -28,18 +23,17 @@ const isModifiedClick = (event: React.MouseEvent) => event.altKey || event.button !== 0; -export const NotificationItem = ({ +const NotificationItemContent = ({ item, - onArchive, - onOpen, - onToggleRead, - variant = "full", -}: NotificationItemProps) => { + variant, +}: { + item: NotificationItemView; + variant: NonNullable<NotificationItemProps["variant"]>; +}) => { const t = useTranslations("core.global.notifications"); const actor = item.actors.at(0); - const linkable = item.available && item.target !== null; - const content = ( + return ( <> <span className="relative shrink-0"> {actor && item.available ? ( @@ -86,9 +80,56 @@ export const NotificationItem = ({ ) : 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"; +const NotificationItemActions = ({ + item, + onArchive, + onToggleRead, +}: Pick<NotificationItemProps, "item" | "onArchive" | "onToggleRead">) => { + const t = useTranslations("core.global.notifications"); + const toggleReadLabel = item.unread ? t("mark_read") : t("mark_unread"); + + return ( + <span className="flex shrink-0 items-center gap-1 p-1"> + {onToggleRead ? ( + <Button + aria-label={toggleReadLabel} + onClick={() => onToggleRead(item)} + size="icon-sm" + title={toggleReadLabel} + variant="ghost" + > + {item.unread ? <MailOpenIcon /> : <MailIcon />} + </Button> + ) : null} + {onArchive ? ( + <Button + aria-label={t("archive")} + onClick={() => onArchive(item)} + size="icon-sm" + title={t("archive")} + variant="ghost" + > + <ArchiveIcon /> + </Button> + ) : null} + </span> + ); +}; + +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 = <NotificationItemContent item={item} variant={variant} />; return ( <li @@ -97,10 +138,10 @@ export const NotificationItem = ({ item.unread && "bg-primary/5", )} > - {linkable ? ( + {linkTarget !== null ? ( <a className={itemClassName} - href={item.target ?? undefined} + href={linkTarget} onClick={event => { const newTab = isModifiedClick(event); if (!newTab) event.preventDefault(); @@ -120,30 +161,11 @@ export const NotificationItem = ({ )} {variant === "full" && (onToggleRead || onArchive) ? ( - <span className="flex shrink-0 items-center gap-1 p-1"> - {onToggleRead ? ( - <Button - aria-label={item.unread ? t("mark_read") : t("mark_unread")} - onClick={() => onToggleRead(item)} - size="icon-sm" - title={item.unread ? t("mark_read") : t("mark_unread")} - variant="ghost" - > - {item.unread ? <MailOpenIcon /> : <MailIcon />} - </Button> - ) : null} - {onArchive ? ( - <Button - aria-label={t("archive")} - onClick={() => onArchive(item)} - size="icon-sm" - title={t("archive")} - variant="ghost" - > - <ArchiveIcon /> - </Button> - ) : null} - </span> + <NotificationItemActions + item={item} + onArchive={onArchive} + onToggleRead={onToggleRead} + /> ) : null} </li> ); diff --git a/packages/vitnode/src/views/notifications/notification-state-store.test.ts b/packages/vitnode/src/views/notifications/notification-state-store.test.ts index 09c468a31..9012c7271 100644 --- a/packages/vitnode/src/views/notifications/notification-state-store.test.ts +++ b/packages/vitnode/src/views/notifications/notification-state-store.test.ts @@ -7,7 +7,6 @@ describe("notification state store", () => { const store = createNotificationStateStore(); expect(store.apply(1, { revision: 5, unread: 3 })).toBe(true); - // A WebSocket update overtook the session request that is now arriving. 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); diff --git a/packages/vitnode/src/views/notifications/notification-state-store.ts b/packages/vitnode/src/views/notifications/notification-state-store.ts index 1c38fe227..6fb90dcab 100644 --- a/packages/vitnode/src/views/notifications/notification-state-store.ts +++ b/packages/vitnode/src/views/notifications/notification-state-store.ts @@ -8,13 +8,6 @@ export interface NotificationStateSnapshot extends NotificationState { type Listener = () => void; -/** - * The unread count the bell shows, owned by nobody but this store. Three - * sources feed it - the session payload, realtime messages and explicit - * refreshes - and each carries the server's per-user revision. Whatever - * arrives, only a strictly newer revision replaces what is here, so a slow - * session response can never undo a realtime update that overtook it. - */ export const createNotificationStateStore = () => { let snapshot: NotificationStateSnapshot | null = null; const listeners = new Set<Listener>(); @@ -22,7 +15,6 @@ export const createNotificationStateStore = () => { const emit = () => listeners.forEach(listener => listener()); return { - /** Returns `true` when `next` was newer and replaced the snapshot. */ apply: (userId: number, next: NotificationState): boolean => { if (snapshot?.userId === userId && next.revision <= snapshot.revision) { return false; @@ -38,7 +30,6 @@ export const createNotificationStateStore = () => { return true; }, get: () => snapshot, - /** Forget everything - on sign-out, or when another user signs in. */ reset: () => { if (snapshot === null) return; snapshot = null; @@ -54,27 +45,20 @@ export const createNotificationStateStore = () => { }; }; -export type NotificationStateStore = ReturnType< - typeof createNotificationStateStore ->; - -/** One per browser tab. Tabs stay in step through the shared WebSocket. */ export const notificationStateStore = createNotificationStateStore(); -/** The current user's unread state, or `null` before anything arrived. */ +const NO_SERVER_SNAPSHOT = () => null; + export const useNotificationState = ( - userId: null | number | undefined, - store: NotificationStateStore = notificationStateStore, + userId: number, ): NotificationState | null => { - // The server never holds anyone's count, so hydration always starts from - // `null` - matching the HTML - and picks up the live value right after. const snapshot = React.useSyncExternalStore( - store.subscribe, - store.get, - () => null, + notificationStateStore.subscribe, + notificationStateStore.get, + NO_SERVER_SNAPSHOT, ); - return snapshot && snapshot.userId === userId + 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 index 593f29d7f..12c61f9be 100644 --- a/packages/vitnode/src/views/notifications/notifications-actions.ts +++ b/packages/vitnode/src/views/notifications/notifications-actions.ts @@ -1,4 +1,7 @@ -import type { NotificationState } from "@/lib/notifications/types"; +import type { + NotificationEmailMode, + NotificationState, +} from "@/lib/notifications/types"; import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; @@ -9,7 +12,6 @@ const failed = (response: { status: number }): never => { throw new NotificationsRequestError(response.status); }; -/** Marks one item read through the activity the user saw. */ export const markNotificationReadInBrowser = async ({ id, throughSeq, @@ -22,7 +24,6 @@ export const markNotificationReadInBrowser = async ({ args: { body: { throughSeq }, params: { id } }, method: "post", module: "notifications", - options: { credentials: "include" }, path: "/{id}/read", }); @@ -39,7 +40,6 @@ export const markNotificationUnreadInBrowser = async ({ args: { params: { id } }, method: "post", module: "notifications", - options: { credentials: "include" }, path: "/{id}/unread", }); @@ -56,7 +56,6 @@ export const archiveNotificationInBrowser = async ({ args: { params: { id } }, method: "post", module: "notifications", - options: { credentials: "include" }, path: "/{id}/archive", }); @@ -73,7 +72,6 @@ export const markAllNotificationsReadInBrowser = async ({ args: { body: { category } }, method: "post", module: "notifications", - options: { credentials: "include" }, path: "/read-all", }); @@ -84,7 +82,7 @@ export interface UpdateNotificationPreferencesBody { types?: Record< string, { - email?: "daily" | "immediate" | "none" | "weekly"; + email?: NotificationEmailMode; inApp?: boolean; push?: boolean; } @@ -99,7 +97,6 @@ export const updateNotificationPreferencesInBrowser = async ( args: { body }, method: "put", module: "notifications", - options: { credentials: "include" }, 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 index b21b6bf4e..1f0408909 100644 --- a/packages/vitnode/src/views/notifications/notifications-bell-content.tsx +++ b/packages/vitnode/src/views/notifications/notifications-bell-content.tsx @@ -23,11 +23,10 @@ import type { NotificationItemView } from "./notifications-query"; import { NotificationItem } from "./notification-item"; -export const NOTIFICATIONS_HREF = "/notifications"; +const NOTIFICATIONS_HREF = "/notifications"; export const NOTIFICATION_SETTINGS_HREF = "/settings/notifications"; -/** `99+` past 99, so the badge never outgrows the icon. */ -export const formatUnreadBadge = (count: number): null | string => { +const formatUnreadBadge = (count: number): null | string => { if (count <= 0) return null; return count > 99 ? "99+" : String(count); @@ -47,11 +46,6 @@ export interface NotificationsBellContentProps { unread: number; } -/** - * The header bell. Its number comes from the session and realtime state, never - * from the list; the list is fetched only once the dropdown opens, and opening - * it never marks anything read. - */ export const NotificationsBellContent = ({ onMarkAllRead, onOpenChange, diff --git a/packages/vitnode/src/views/notifications/notifications-query.ts b/packages/vitnode/src/views/notifications/notifications-query.ts index 819a2da90..a5ee8557b 100644 --- a/packages/vitnode/src/views/notifications/notifications-query.ts +++ b/packages/vitnode/src/views/notifications/notifications-query.ts @@ -1,5 +1,7 @@ import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import type { NotificationEmailMode } from "@/lib/notifications/types"; + import { CONFIG_PLUGIN } from "@/config"; import { OPERATIONAL_STALE_TIME, @@ -50,7 +52,6 @@ export class NotificationsRequestError extends Error { readonly status: number; } -/** Everything private to the signed-in user - dropped on sign-out. */ export const NOTIFICATIONS_IDENTITY_ROOT = ["notifications", "user"] as const; export const notificationsListRoot = (userId: number) => @@ -92,10 +93,9 @@ export const fetchNotificationsPage = async ({ return await response.json(); }; -export const NOTIFICATIONS_PAGE_SIZE = 20; -export const NOTIFICATIONS_RECENT_SIZE = 8; +const NOTIFICATIONS_PAGE_SIZE = 20; +const NOTIFICATIONS_RECENT_SIZE = 8; -/** The full Notifications Center list. */ export const notificationsInfiniteQueryOptions = ({ filter, userId, @@ -123,10 +123,6 @@ export const notificationsInfiniteQueryOptions = ({ staleTime: OPERATIONAL_STALE_TIME, }); -/** - * The few items the bell's dropdown shows. Only fetched when the dropdown - * opens - the bell's number comes from the session, never from this list. - */ export const recentNotificationsQueryOptions = ({ userId, }: { @@ -143,9 +139,6 @@ export const recentNotificationsQueryOptions = ({ staleTime: OPERATIONAL_STALE_TIME, }); -export const notificationStateQueryKey = (userId: number) => - [...NOTIFICATIONS_IDENTITY_ROOT, userId, "state"] as const; - export const fetchNotificationState = async () => { const response = await fetcher({ plugin: CONFIG_PLUGIN.pluginId, @@ -161,9 +154,9 @@ export const fetchNotificationState = async () => { export interface NotificationPreferenceTypeView { category: string; categoryLabel: string; - defaultEmail: "daily" | "immediate" | "none" | "weekly"; + defaultEmail: NotificationEmailMode; description: null | string; - emailModes: ("daily" | "immediate" | "none" | "weekly")[]; + emailModes: NotificationEmailMode[]; id: string; inAppAvailable: boolean; label: string; @@ -172,7 +165,7 @@ export interface NotificationPreferenceTypeView { pluginId: string; pushAvailable: boolean; value: { - email: "daily" | "immediate" | "none" | "weekly"; + email: NotificationEmailMode; inApp: boolean; push: boolean; }; diff --git a/packages/vitnode/src/views/notifications/settings/email-channel.ts b/packages/vitnode/src/views/notifications/settings/email-channel.ts index efe69f751..59eae8738 100644 --- a/packages/vitnode/src/views/notifications/settings/email-channel.ts +++ b/packages/vitnode/src/views/notifications/settings/email-channel.ts @@ -1,10 +1,9 @@ +import type { NotificationEmailMode } from "@/lib/notifications/types"; import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; -type EmailMode = NotificationPreferenceTypeView["value"]["email"]; - export type EmailChannelState = "off" | "on" | "unavailable"; -export const emailEditableTypes = (types: NotificationPreferenceTypeView[]) => +const emailEditableTypes = (types: NotificationPreferenceTypeView[]) => types.filter(type => !type.locked && type.emailModes.length > 0); export const emailChannelState = ( @@ -16,7 +15,9 @@ export const emailChannelState = ( return editable.some(type => type.value.email !== "none") ? "on" : "off"; }; -const turnOnMode = (type: NotificationPreferenceTypeView): EmailMode => { +const turnOnMode = ( + type: NotificationPreferenceTypeView, +): NotificationEmailMode => { if (type.defaultEmail !== "none") return type.defaultEmail; return type.emailModes.includes("daily") ? "daily" : "immediate"; @@ -24,8 +25,8 @@ const turnOnMode = (type: NotificationPreferenceTypeView): EmailMode => { export const emailOnPatch = ( types: NotificationPreferenceTypeView[], - before: null | Record<string, EmailMode>, -): Record<string, { email: EmailMode }> => + before: null | Record<string, NotificationEmailMode>, +): Record<string, { email: NotificationEmailMode }> => Object.fromEntries( emailEditableTypes(types).map(type => { const previous = before?.[type.id]; diff --git a/packages/vitnode/src/views/notifications/settings/type-item.tsx b/packages/vitnode/src/views/notifications/settings/type-item.tsx index 3a9ebfe11..14b5f395c 100644 --- a/packages/vitnode/src/views/notifications/settings/type-item.tsx +++ b/packages/vitnode/src/views/notifications/settings/type-item.tsx @@ -7,25 +7,18 @@ import { MailIcon, SmartphoneIcon, } from "lucide-react"; -import { AnimatePresence, motion, useReducedMotion } from "motion/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"; -import { - REVEAL_EXIT_TRANSITION, - REVEAL_HIDDEN, - REVEAL_SHOWN, - REVEAL_TRANSITION, -} from "@/lib/motion"; - -type EmailMode = NotificationPreferenceTypeView["value"]["email"]; export type NotificationTypePatch = Partial< NotificationPreferenceTypeView["value"] @@ -112,6 +105,124 @@ const ChannelSwitch = ({ ); }; +const EMAIL_DESCRIPTION_KEY = { + daily: "types.email_daily", + immediate: "types.email_immediate", + none: "types.email_off", + weekly: "types.email_weekly", +} as const satisfies Record<NotificationEmailMode, string>; + +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 ( + <div className="has-focus-visible:ring-ring/50 [@media(hover:hover)]:hover:bg-muted/40 relative flex items-center gap-3 px-4 py-3 has-focus-visible:ring-3 has-focus-visible:ring-inset"> + <div className="flex min-w-0 flex-1 flex-col gap-0.5"> + <h4 className="flex items-center gap-1.5 text-sm font-medium text-pretty"> + {type.locked ? ( + <LockIcon + aria-label={t("locked")} + className="text-muted-foreground size-3.5 shrink-0" + /> + ) : null} + <button + aria-controls={panelId} + aria-expanded={isOpen} + className="text-start outline-none after:absolute after:inset-0" + onClick={onToggle} + type="button" + > + {type.label} + </button> + </h4> + {type.description ? ( + <span className="text-muted-foreground truncate text-xs"> + {type.description} + </span> + ) : null} + </div> + <span className="flex shrink-0 items-center gap-0.5"> + <ChannelChip + available={type.inAppAvailable} + Icon={BellIcon} + label={t("types.summary.list")} + on={type.value.inApp} + /> + <ChannelChip + available={type.pushAvailable} + Icon={SmartphoneIcon} + label={t("types.summary.push")} + on={type.value.push} + /> + <ChannelChip + available={emailShown} + Icon={MailIcon} + label={t("types.summary.email")} + on={emailOn} + /> + </span> + <ChevronDownIcon + aria-hidden + className={cn( + "text-muted-foreground size-4 shrink-0 transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none", + isOpen && "rotate-180", + )} + /> + </div> + ); +}; + +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 ( + <> + <label className="sr-only" htmlFor={frequencyId}> + {t("types.frequency_label", { type: type.label })} + </label> + <NativeSelect + className="w-44 text-base sm:text-sm" + id={frequencyId} + onChange={event => { + const next = frequencies.find(mode => mode === event.target.value); + if (next) onSelect(next); + }} + size="sm" + value={type.value.email} + > + {frequencies.map(mode => ( + <NativeSelectOption key={mode} value={mode}> + {t(`email_modes.${mode}`)} + </NativeSelectOption> + ))} + </NativeSelect> + </> + ); +}; + export const NotificationTypeItem = ({ isOpen, onChange, @@ -125,175 +236,87 @@ export const NotificationTypeItem = ({ }) => { const t = useTranslations("core.auth.settings.notifications"); const panelId = React.useId(); - const frequencyId = React.useId(); - const shouldReduceMotion = useReducedMotion(); const emailOn = type.value.email !== "none"; const emailShown = type.emailModes.length > 0 || type.locked; - const lastFrequencyRef = React.useRef<EmailMode>( + const lastFrequencyRef = React.useRef<NotificationEmailMode>( emailOn ? type.value.email : "daily", ); const frequencies = type.emailModes.filter(mode => mode !== "none"); + const canPickFrequency = emailOn && !type.locked && frequencies.length > 1; return ( <li className="border-b last:border-b-0"> - <div className="has-focus-visible:ring-ring/50 [@media(hover:hover)]:hover:bg-muted/40 relative flex items-center gap-3 px-4 py-3 has-focus-visible:ring-[3px] has-focus-visible:ring-inset"> - <div className="flex min-w-0 flex-1 flex-col gap-0.5"> - <h4 className="flex items-center gap-1.5 text-sm font-medium text-pretty"> - {type.locked ? ( - <LockIcon - aria-label={t("locked")} - className="text-muted-foreground size-3.5 shrink-0" - /> - ) : null} - <button - aria-controls={panelId} - aria-expanded={isOpen} - className="text-start outline-none after:absolute after:inset-0" - onClick={onToggle} - type="button" - > - {type.label} - </button> - </h4> - {type.description ? ( - <span className="text-muted-foreground truncate text-xs"> - {type.description} - </span> - ) : null} - </div> - <span className="flex shrink-0 items-center gap-0.5"> - <ChannelChip - available={type.inAppAvailable} - Icon={BellIcon} - label={t("types.summary.list")} - on={type.value.inApp} - /> - <ChannelChip - available={type.pushAvailable} - Icon={SmartphoneIcon} - label={t("types.summary.push")} - on={type.value.push} - /> - <ChannelChip - available={emailShown} - Icon={MailIcon} - label={t("types.summary.email")} - on={emailOn} - /> - </span> - <ChevronDownIcon - aria-hidden - className={cn( - "text-muted-foreground size-4 shrink-0 transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none", - isOpen && "rotate-180", - )} - /> - </div> + <NotificationTypeSummary + emailOn={emailOn} + emailShown={emailShown} + isOpen={isOpen} + onToggle={onToggle} + panelId={panelId} + type={type} + /> - <AnimatePresence initial={false}> - {isOpen ? ( - <motion.div - animate={REVEAL_SHOWN} - className="overflow-y-clip" - exit={ - shouldReduceMotion - ? undefined - : { ...REVEAL_HIDDEN, transition: REVEAL_EXIT_TRANSITION } - } - id={panelId} - initial={shouldReduceMotion ? false : REVEAL_HIDDEN} - key="panel" - transition={REVEAL_TRANSITION} - > - <div className="bg-muted/30 flex flex-col divide-y border-t px-4 py-1"> - {type.locked ? ( - <p className="text-muted-foreground flex items-center gap-1.5 py-3 text-sm"> - <LockIcon aria-hidden className="size-3.5" /> - {type.mandatory ? t("types.mandatory") : t("types.locked")} - </p> - ) : null} - {type.inAppAvailable ? ( - <ChannelSwitch - description={t("types.list_desc")} - disabled={type.locked} - Icon={BellIcon} - label={t("types.summary.list")} - onChange={inApp => { - onChange({ inApp }); - }} - value={type.value.inApp} - /> - ) : null} - {type.pushAvailable ? ( - <ChannelSwitch - description={t("types.push_desc")} - disabled={type.locked} - Icon={SmartphoneIcon} - label={t("types.summary.push")} - onChange={push => { - onChange({ push }); + <RevealPanel className="overflow-y-clip" id={panelId} open={isOpen}> + <div className="bg-muted/30 flex flex-col divide-y border-t px-4 py-1"> + {type.locked ? ( + <p className="text-muted-foreground flex items-center gap-1.5 py-3 text-sm"> + <LockIcon aria-hidden className="size-3.5" /> + {type.mandatory ? t("types.mandatory") : t("types.locked")} + </p> + ) : null} + {type.inAppAvailable ? ( + <ChannelSwitch + description={t("types.list_desc")} + disabled={type.locked} + Icon={BellIcon} + label={t("types.summary.list")} + onChange={inApp => { + onChange({ inApp }); + }} + value={type.value.inApp} + /> + ) : null} + {type.pushAvailable ? ( + <ChannelSwitch + description={t("types.push_desc")} + disabled={type.locked} + Icon={SmartphoneIcon} + label={t("types.summary.push")} + onChange={push => { + onChange({ push }); + }} + value={type.value.push} + /> + ) : null} + {emailShown ? ( + <ChannelSwitch + description={t(EMAIL_DESCRIPTION_KEY[type.value.email])} + disabled={type.locked} + Icon={MailIcon} + label={t("types.summary.email")} + onChange={on => { + if (!on && emailOn) lastFrequencyRef.current = type.value.email; + onChange({ email: on ? lastFrequencyRef.current : "none" }); + }} + value={emailOn} + > + {canPickFrequency ? ( + <EmailFrequencySelect + frequencies={frequencies} + onSelect={mode => { + lastFrequencyRef.current = mode; + onChange({ email: mode }); }} - value={type.value.push} + type={type} /> + ) : type.locked && emailOn ? ( + <span className="text-muted-foreground text-sm"> + {t(`email_modes.${type.value.email}`)} + </span> ) : null} - {emailShown ? ( - <ChannelSwitch - description={ - type.value.email === "daily" - ? t("types.email_daily") - : type.value.email === "weekly" - ? t("types.email_weekly") - : type.value.email === "immediate" - ? t("types.email_immediate") - : t("types.email_off") - } - disabled={type.locked} - Icon={MailIcon} - label={t("types.summary.email")} - onChange={on => { - if (!on && emailOn) - lastFrequencyRef.current = type.value.email; - onChange({ email: on ? lastFrequencyRef.current : "none" }); - }} - value={emailOn} - > - {emailOn && !type.locked && frequencies.length > 1 ? ( - <> - <label className="sr-only" htmlFor={frequencyId}> - {t("types.frequency_label", { type: type.label })} - </label> - <NativeSelect - className="w-44 text-base sm:text-sm" - id={frequencyId} - onChange={event => { - const next = frequencies.find( - mode => mode === event.target.value, - ); - if (!next) return; - lastFrequencyRef.current = next; - onChange({ email: next }); - }} - size="sm" - value={type.value.email} - > - {frequencies.map(mode => ( - <NativeSelectOption key={mode} value={mode}> - {t(`email_modes.${mode}`)} - </NativeSelectOption> - ))} - </NativeSelect> - </> - ) : type.locked && emailOn ? ( - <span className="text-muted-foreground text-sm"> - {t(`email_modes.${type.value.email}`)} - </span> - ) : null} - </ChannelSwitch> - ) : null} - </div> - </motion.div> - ) : null} - </AnimatePresence> + </ChannelSwitch> + ) : null} + </div> + </RevealPanel> </li> ); }; diff --git a/packages/vitnode/src/ws/notifications.ts b/packages/vitnode/src/ws/notifications.ts index b06bb4f93..58e140477 100644 --- a/packages/vitnode/src/ws/notifications.ts +++ b/packages/vitnode/src/ws/notifications.ts @@ -6,10 +6,7 @@ import { createWebSocketChannel } from "./types"; export type VitNodeNotificationType = "error" | "info" | "success" | "warning"; -/** - * A transient toast pushed to a single user over the WebSocket. Persistent - * notifications go through `c.get("notifications").publish` instead. - */ +/** A notification pushed to a single user over the WebSocket. */ export interface VitNodeNotification { description?: string; title: string; @@ -25,10 +22,6 @@ export const notificationsChannel = createWebSocketChannel< pluginId: CONFIG_PLUGIN.pluginId, }); -/** - * The canonical unread state of the signed-in user. Every committed inbox - * change sends the absolute count with a per-user revision, never a +1/-1. - */ export const notificationsStateChannel = createWebSocketChannel< never, NotificationStateMessage From 6aa6720824374912019585a1c19c0815cc7de756 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axen@vitnode.com> Date: Sun, 4 Oct 2026 19:21:06 +0200 Subject: [PATCH 6/6] =?UTF-8?q?refactor(cron):=20=E2=99=BB=EF=B8=8F=20Simp?= =?UTF-8?q?lify=20cron=20job=20status=20rendering=20with=20new=20`CronStat?= =?UTF-8?q?usBadge`=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/content-modules.gen.ts | 2 + .../core/advanced/cron/cron-table-content.tsx | 54 +++++++++---------- .../advanced/queue/badges/status-badge.tsx | 9 ++-- .../system-logs/badges/badge-type-log.tsx | 4 +- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/apps/web/src/content-modules.gen.ts b/apps/web/src/content-modules.gen.ts index eee602ba3..3d7b6a31b 100644 --- a/apps/web/src/content-modules.gen.ts +++ b/apps/web/src/content-modules.gen.ts @@ -1,3 +1,5 @@ +/* eslint-disable */ + // This file is generated by VitNode. Do not edit it, and do not format it. // // It is rewritten by the `vitnode:plugin-routes` Vite plugin on every 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 9913e5173..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,8 +1,9 @@ -import { cn } from "cn"; +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"; @@ -18,10 +19,30 @@ const jobStatus = (row: CronJobRow): CronJobStatus => { return "on_schedule"; }; -const STATUS_DOT: Record<CronJobStatus, string> = { - on_schedule: "bg-success", - overdue: "bg-warn", - waiting: "bg-muted-foreground/50", +const CronStatusBadge = ({ status }: { status: CronJobStatus }) => { + const t = useTranslations("admin.advanced.cron.list.status"); + + if (status === "on_schedule") { + return ( + <Badge className="border-success/50" variant="success"> + <CircleCheckIcon /> {t(status)} + </Badge> + ); + } + + if (status === "overdue") { + return ( + <Badge className="border-warn/50" variant="warning"> + <TriangleAlertIcon /> {t(status)} + </Badge> + ); + } + + return ( + <Badge variant="secondary"> + <ClockIcon /> {t(status)} + </Badge> + ); }; export const CronTableContent = ({ @@ -54,28 +75,7 @@ export const CronTableContent = ({ { id: "status", header: t("list.status.title"), - cell: ({ row }) => { - const status = jobStatus(row); - - return ( - <span className="inline-flex items-center gap-1.5 whitespace-nowrap"> - <span - aria-hidden - className={cn( - "size-2 shrink-0 rounded-full", - STATUS_DOT[status], - )} - /> - <span - className={ - status === "overdue" ? "text-warn" : "text-muted-foreground" - } - > - {t(`list.status.${status}`)} - </span> - </span> - ); - }, + cell: ({ row }) => <CronStatusBadge status={jobStatus(row)} />, }, { accessorKey: "pluginId", 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 ( - <Badge className="border-blue-500/50 bg-blue-500/10 text-blue-700 dark:text-blue-400"> + <Badge + className="border-primary/50 bg-primary/10 text-primary" + variant="outline" + > <LoaderIcon className="animate-spin" /> {t("processing")} </Badge> ); @@ -23,7 +26,7 @@ export const QueueStatusBadge = ({ status }: { status: QueueTaskStatus }) => { if (status === "completed") { return ( - <Badge className="border-green-500/50 bg-green-500/10 text-green-700 dark:text-green-400"> + <Badge className="border-success/50" variant="success"> <CircleCheckIcon /> {t("completed")} </Badge> ); @@ -31,7 +34,7 @@ export const QueueStatusBadge = ({ status }: { status: QueueTaskStatus }) => { if (status === "failed") { return ( - <Badge className="border-destructive/50 bg-destructive/10 text-destructive"> + <Badge className="border-destructive/50" variant="destructive"> <CircleXIcon /> {t("failed")} </Badge> ); 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 ( - <Badge className="bg-warn/10 border-warn/50 text-warn"> + <Badge className="border-warn/50" variant="warning"> <TriangleAlertIcon /> {t(type)} </Badge> ); @@ -20,7 +20,7 @@ export const BadgeTypeLog = ({ if (type === "error") { return ( - <Badge className="bg-destructive/10 border-destructive/50 text-destructive"> + <Badge className="border-destructive/50" variant="destructive"> <XIcon /> {t(type)} </Badge> );