diff --git a/AGENTS.md b/AGENTS.md index 6a54ca0564..2ea28849d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,7 @@ Pick one indicator per piece of pending work, by what is pending: - Exactly 3–5 colors total. Never use purple or violet prominently. - If you override a background color, you MUST override its text color for contrast. - Prefer semantic design tokens (`bg-background`, `text-foreground`). +- Every action button gets a `lucide-react` icon before its label (`aria-hidden`) - Save, Delete, Reply, Share, Try again, Sign in, submit buttons and confirm buttons in dialogs. The only exception is Cancel (and other dismiss buttons that just close something). - Use the Tailwind spacing scale (`p-4`, `mx-2`) - never arbitrary values (`p-[16px]`). - Use `gap-*` classes for spacing. Never use `space-*`, and never mix margin/padding with gap on the same element. - Use semantic (`items-center`, `justify-between`) and responsive (`md:grid-cols-2`) classes. diff --git a/apps/api/migrations/20261026210127_add_blog_comments_and_moderation/migration.sql b/apps/api/migrations/20261026210127_add_blog_comments_and_moderation/migration.sql new file mode 100644 index 0000000000..d89c4a2c23 --- /dev/null +++ b/apps/api/migrations/20261026210127_add_blog_comments_and_moderation/migration.sql @@ -0,0 +1,54 @@ +CREATE TABLE "blog_comments" ( + "id" serial PRIMARY KEY, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "hiddenAt" timestamp, + "hiddenBy" integer, + "hiddenReason" varchar(500), + "deletedAt" timestamp, + "deletedBy" integer, + "slug" varchar(160) NOT NULL, + "body" jsonb NOT NULL, + "author" integer, + "article" integer NOT NULL, + "parent" integer +); +--> statement-breakpoint +ALTER TABLE "blog_comments" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "core_roles" ADD COLUMN "allowEditOwnContent" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "core_roles" ADD COLUMN "editOwnContentMinutes" integer;--> statement-breakpoint +ALTER TABLE "core_roles" ADD COLUMN "allowDeleteOwnContent" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "core_roles" ADD COLUMN "allowHideOwnContent" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "hiddenReason" varchar(500);--> statement-breakpoint +ALTER TABLE "forum_comments" ADD COLUMN "hiddenReason" varchar(500);--> statement-breakpoint +ALTER TABLE "forum_comments" ADD COLUMN "deletedAt" timestamp;--> statement-breakpoint +ALTER TABLE "forum_comments" ADD COLUMN "deletedBy" integer;--> statement-breakpoint +ALTER TABLE "forum_topics" ADD COLUMN "hiddenReason" varchar(500);--> statement-breakpoint +ALTER TABLE "newsroom_comments" ADD COLUMN "hiddenReason" varchar(500);--> statement-breakpoint +ALTER TABLE "newsroom_comments" ADD COLUMN "deletedAt" timestamp;--> statement-breakpoint +ALTER TABLE "newsroom_comments" ADD COLUMN "deletedBy" integer;--> statement-breakpoint +ALTER TABLE "newsroom_events" ADD COLUMN "hiddenReason" varchar(500);--> statement-breakpoint +ALTER TABLE "newsroom_letters" ADD COLUMN "hiddenReason" varchar(500);--> statement-breakpoint +ALTER TABLE "newsroom_tags" ADD COLUMN "hiddenReason" varchar(500);--> statement-breakpoint +CREATE INDEX "blog_comments_article_parent_created_at_idx" ON "blog_comments" ("article","parent","createdAt");--> statement-breakpoint +CREATE UNIQUE INDEX "blog_comments_slug_key" ON "blog_comments" ("slug");--> statement-breakpoint +CREATE INDEX "blog_comments_author_idx" ON "blog_comments" ("author");--> statement-breakpoint +CREATE INDEX "blog_comments_article_idx" ON "blog_comments" ("article");--> statement-breakpoint +CREATE INDEX "blog_comments_parent_idx" ON "blog_comments" ("parent");--> statement-breakpoint +CREATE INDEX "blog_comments_created_at_idx" ON "blog_comments" ("createdAt");--> statement-breakpoint +CREATE INDEX "blog_comments_updated_at_idx" ON "blog_comments" ("updatedAt");--> statement-breakpoint +CREATE INDEX "blog_comments_status_published_at_idx" ON "blog_comments" ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "blog_comments_hidden_by_idx" ON "blog_comments" ("hiddenBy");--> statement-breakpoint +CREATE INDEX "blog_comments_deleted_by_idx" ON "blog_comments" ("deletedBy");--> statement-breakpoint +CREATE INDEX "forum_comments_deleted_by_idx" ON "forum_comments" ("deletedBy");--> statement-breakpoint +CREATE INDEX "newsroom_comments_deleted_by_idx" ON "newsroom_comments" ("deletedBy");--> statement-breakpoint +ALTER TABLE "blog_comments" ADD CONSTRAINT "blog_comments_hiddenBy_core_users_id_fkey" FOREIGN KEY ("hiddenBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "blog_comments" ADD CONSTRAINT "blog_comments_deletedBy_core_users_id_fkey" FOREIGN KEY ("deletedBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "blog_comments" ADD CONSTRAINT "blog_comments_author_core_users_id_fkey" FOREIGN KEY ("author") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "blog_comments" ADD CONSTRAINT "blog_comments_article_blog_posts_id_fkey" FOREIGN KEY ("article") REFERENCES "blog_posts"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "blog_comments" ADD CONSTRAINT "blog_comments_parent_blog_comments_id_fkey" FOREIGN KEY ("parent") REFERENCES "blog_comments"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "forum_comments" ADD CONSTRAINT "forum_comments_deletedBy_core_users_id_fkey" FOREIGN KEY ("deletedBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_comments" ADD CONSTRAINT "newsroom_comments_deletedBy_core_users_id_fkey" FOREIGN KEY ("deletedBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE; \ No newline at end of file diff --git a/apps/api/migrations/20261026210127_add_blog_comments_and_moderation/snapshot.json b/apps/api/migrations/20261026210127_add_blog_comments_and_moderation/snapshot.json new file mode 100644 index 0000000000..5e665bc1ff --- /dev/null +++ b/apps/api/migrations/20261026210127_add_blog_comments_and_moderation/snapshot.json @@ -0,0 +1,21809 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "50263672-7a4f-48ea-9374-78b5474879d5", + "prevIds": [ + "4b66f492-fb3f-4ba0-a722-b4787d0ef9b8" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_translation_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_containers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_drafts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_field_locks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_submissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_analysis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_reasons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_reports", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_popularity", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_lock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_results", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_topics", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_bulletins", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_bulletins_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_related_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_related_explainers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_letters", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_stories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_stories_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags_related_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags_translations", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "definitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "policies", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "acquiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerDefinitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseValues", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseUpdatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reviewerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "decidedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'automatic'", + "generated": null, + "identity": null, + "name": "altPolicy", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reportId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "requiresDetails", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetType", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reporterId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetTitle", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'new'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "assigneeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolution", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolutionNote", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedById", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeFamily", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "openKey", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditOwnContent", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "editOwnContentMinutes", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowDeleteOwnContent", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowHideOwnContent", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isHidden", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "imageId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "logScore", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "clicks", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "comments", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "owner", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastManualAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "varchar(214)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "componentId", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "versions", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasUnknownVersion", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coreVersion", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "result", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastAttemptAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSuccessAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "etag", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastModified", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastAttemptAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSuccessAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedBy", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "article", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedBy", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "topic", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "forum", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "varchar(80)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "article", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "newsroom_events_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primaryTag", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "desk", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "periodStart", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_budget_periods_scope_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_run_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "costSource", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reconciledAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_reconcile_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "callId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_cost_adjustments_call_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "budgetPeriodId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_run_period_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actionKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_action_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "leaseExpiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotencyKey\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_translation_sources_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_definition_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_definition_parent_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_doc_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_pending_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "containerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_queue_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_file_language_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileFingerprint", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_analysis_file_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status <> 'completed'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_state_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reportId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_report_events_report_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_report_reasons_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "assigneeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_assignee_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reporterId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_reporter_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeFamily", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scopeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_scope_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "eventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"eventId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "completedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"completedAt\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_completed_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dedupeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"dedupeKey\" IS NOT NULL AND status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_dedupe_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "to_tsvector('simple'::regconfig, \"title\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_title_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "imageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_image_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "containerType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "containerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_container_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "logScore", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_popularity_log_score_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_popularity" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "article", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_article_parent_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "article", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_article_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deletedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_deleted_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_topic_parent_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_topic_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deletedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_deleted_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "forum", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_forum_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "article", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_article_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deletedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_deleted_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primaryTag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_primary_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_related_events_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_related_events_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_sessions_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_related_explainers_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_related_explainers_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "desk", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_desk_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_code_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_related_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_related_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_translation_sources_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_content_containers_q6QIucJ4HJ7B_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_documents_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_drafts_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_field_locks_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_content_submissions_authorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": false, + "columns": [ + "reviewerId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_content_submissions_reviewerId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_languageCode_core_languages_code_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_analysis_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_analysis_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_state_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "reportId" + ], + "schemaTo": "public", + "tableTo": "core_moderation_reports", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderation_report_events_4CqHOxfheaUm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_report_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "reporterId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_reporterId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "assigneeId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_assigneeId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "resolvedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_resolvedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "imageId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_search_index_imageId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "deletedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_deletedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "article" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_comments_article_blog_posts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "blog_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_parent_blog_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_posts_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "deletedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_comments_deletedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "topic" + ], + "schemaTo": "public", + "tableTo": "forum_topics", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "forum_replies_topic_forum_topics_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "forum_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_parent_forum_replies_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "forum" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "forum_topics_forum_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_articles_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_articles_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_bulletins", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_bulletins_translations_kgMZwrpzwwU7_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_bulletins_translations_rkkiMJQiOrEo_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "deletedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_deletedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "article" + ], + "schemaTo": "public", + "tableTo": "newsroom_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_comments_article_newsroom_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "newsroom_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_parent_newsroom_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "primaryTag" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_primaryTag_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_gallery_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_related_events_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_related_events_0xHNSLM3JAJj_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_sessions_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_tags_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_translations_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_categories_9dOmLv3k6xDX_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_categories_mnxKvproEUm5_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_faq_itemId_newsroom_explainers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_related_explainers_TFvIU3TogSGy_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_related_explainers_74JhaXF5PsDV_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_tags_itemId_newsroom_explainers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_translations_hcPB0jdTm8MV_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_translations_qqggtF6jhcsZ_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_letters_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_letters_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "desk" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_letters_desk_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_stories_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_stories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_stories_gallery_itemId_newsroom_stories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_tags_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_tags_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_related_tags_itemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_related_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_translations_itemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_tags_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "itemType", + "itemId" + ], + "nameExplicit": false, + "name": "core_search_popularity_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_search_popularity" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_bulletins_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_related_events_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_events_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_related_explainers_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_stories_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_tags_related_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_tags_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_containers_pkey", + "schema": "public", + "table": "core_content_containers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_documents_pkey", + "schema": "public", + "table": "core_content_documents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_drafts_pkey", + "schema": "public", + "table": "core_content_drafts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_field_locks_pkey", + "schema": "public", + "table": "core_content_field_locks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_submissions_pkey", + "schema": "public", + "table": "core_content_submissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_pkey", + "schema": "public", + "table": "core_files_alt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_analysis_pkey", + "schema": "public", + "table": "core_files_alt_analysis", + "entityType": "pks" + }, + { + "columns": [ + "fileId" + ], + "nameExplicit": false, + "name": "core_files_alt_state_pkey", + "schema": "public", + "table": "core_files_alt_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_events_pkey", + "schema": "public", + "table": "core_moderation_report_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_reasons_pkey", + "schema": "public", + "table": "core_moderation_report_reasons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_reports_pkey", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_update_lock_pkey", + "schema": "public", + "table": "core_update_lock", + "entityType": "pks" + }, + { + "columns": [ + "componentId" + ], + "nameExplicit": false, + "name": "core_update_results_pkey", + "schema": "public", + "table": "core_update_results", + "entityType": "pks" + }, + { + "columns": [ + "url" + ], + "nameExplicit": false, + "name": "core_update_sources_pkey", + "schema": "public", + "table": "core_update_sources", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_comments_pkey", + "schema": "public", + "table": "blog_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_replies_pkey", + "schema": "public", + "table": "forum_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_topics_pkey", + "schema": "public", + "table": "forum_topics", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_articles_pkey", + "schema": "public", + "table": "newsroom_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_bulletins_pkey", + "schema": "public", + "table": "newsroom_bulletins", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_categories_pkey", + "schema": "public", + "table": "newsroom_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_comments_pkey", + "schema": "public", + "table": "newsroom_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_events_pkey", + "schema": "public", + "table": "newsroom_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_events_sessions_pkey", + "schema": "public", + "table": "newsroom_events_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_explainers_pkey", + "schema": "public", + "table": "newsroom_explainers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_explainers_faq_pkey", + "schema": "public", + "table": "newsroom_explainers_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_letters_pkey", + "schema": "public", + "table": "newsroom_letters", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_pages_pkey", + "schema": "public", + "table": "newsroom_pages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_stories_pkey", + "schema": "public", + "table": "newsroom_stories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_tags_pkey", + "schema": "public", + "table": "newsroom_tags", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_moderation_report_reasons_code_key", + "schema": "public", + "table": "core_moderation_report_reasons", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "openKey" + ], + "nullsNotDistinct": false, + "name": "core_moderation_reports_openKey_key", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/web/content/docs/content-engine/comment-actions-delete.webp b/apps/web/content/docs/content-engine/comment-actions-delete.webp new file mode 100644 index 0000000000..8030ebfdb6 Binary files /dev/null and b/apps/web/content/docs/content-engine/comment-actions-delete.webp differ diff --git a/apps/web/content/docs/content-engine/comment-actions-edit.webp b/apps/web/content/docs/content-engine/comment-actions-edit.webp new file mode 100644 index 0000000000..45e923648e Binary files /dev/null and b/apps/web/content/docs/content-engine/comment-actions-edit.webp differ diff --git a/apps/web/content/docs/content-engine/comment-actions-hidden.webp b/apps/web/content/docs/content-engine/comment-actions-hidden.webp new file mode 100644 index 0000000000..4762f01a8c Binary files /dev/null and b/apps/web/content/docs/content-engine/comment-actions-hidden.webp differ diff --git a/apps/web/content/docs/content-engine/comment-actions-menu.webp b/apps/web/content/docs/content-engine/comment-actions-menu.webp new file mode 100644 index 0000000000..76931385bf Binary files /dev/null and b/apps/web/content/docs/content-engine/comment-actions-menu.webp differ diff --git a/apps/web/content/docs/content-engine/comment-actions-modcp.webp b/apps/web/content/docs/content-engine/comment-actions-modcp.webp new file mode 100644 index 0000000000..f4a869dd89 Binary files /dev/null and b/apps/web/content/docs/content-engine/comment-actions-modcp.webp differ diff --git a/apps/web/content/docs/content-engine/comments-api.mdx b/apps/web/content/docs/content-engine/comments-api.mdx new file mode 100644 index 0000000000..822dc5241b --- /dev/null +++ b/apps/web/content/docs/content-engine/comments-api.mdx @@ -0,0 +1,182 @@ +--- +title: Comments API +description: Reference for @vitnode/core/tanstack/comments, covering useContentComments, its typed callbacks and controller, the ContentComments section, ContentCommentsRoot for custom layouts, and the comment item, actions and composer components. +icon: SquareFunction +--- + +`@vitnode/core/tanstack/comments` turns a Content Engine content type with `thread` into a comment section. `useContentComments` holds the thread's state and actions, and the components render them. For a guided setup, follow [Comments](/docs/content-engine/threads), [Comment actions](/docs/content-engine/threads-editing) and [Replies](/docs/content-engine/threads-frontend). + +Everything here is browser code. It calls the Content Engine's own routes for the thread, permissions, writes, deletes, hiding and reports, and those routes decide every permission. + +## `useContentComments` + +```tsx +import { useContentComments } from '@vitnode/core/tanstack/comments' + +const comments = useContentComments({ queryKey, thread, ...actions }) +``` + +### Options + +| Name | Type | Required | Description | +| ------------- | --------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `queryKey` | `QueryKey` | Yes | The thread's cache key prefix, unique per root record, e.g. `[manifest.id, "comments", article.id]`. The thread is cached under `[...queryKey, "thread"]` and the permissions under `[...queryKey, "permissions", ...]`, so invalidating `queryKey` refreshes both | +| `thread` | `({ after, first }) => Promise` | Yes | `GET /thread` with `root`, `first` and `after` | +| `maxDepth` | `number` | No | The type's `thread.maxDepth`. Without it the comments are flat and **Reply** never shows | +| `pageSize` | `number` | No | Top-level comments per page, 1-50. Defaults to `20` | +| `permissions` | `(ids) => Promise` | No | `GET /permissions` with `ids` joined by commas. Without it every comment has no actions | +| `create` | `({ body, parentId }) => Promise` | No | `POST /` with the root record, `body` and `parent: parentId`. Without it there is no composer | +| `edit` | `{ load(id), save(id, { body, expectedVersion }) }` | No | `GET /{id}/edit` and `PATCH /{id}`. `expectedVersion` is a number with `editorial` and `undefined` without it | +| `delete` | `(id) => Promise` | No | `POST /{id}/delete` | +| `visibility` | `{ hide(id, { reason }), unhide(id) }` | No | `POST /{id}/hide` with an optional `reason` (`null` when empty, at most 500 characters) and `POST /{id}/unhide` | +| `report` | `(id) => ContentTarget` | No | `contentTarget(manifest.id, commentContentType, id)` from `@vitnode/core/content` | +| `record` | `ZodType` | No | Validates every loaded comment's public record and gives it a type. The thread route types records loosely, so pass it when your own rendering reads their fields | +| `field` | `string` | No | The rich-text field with the comment's text. Defaults to `"body"` | +| `onChanged` | `(queryClient) => void \| Promise` | No | Refreshes your other queries after a successful write, such as a comment count elsewhere on the page | + +### Callback contract + +- **Write each `fetcher` call inline** and return its response unchanged. The plugin, module, path, arguments and answer then stay typed from your routes; the hook infers the comment id type (`number`, or `string` for a [uuid](/docs/content-engine/id-strategy) type) from `thread`. +- **Don't check the status yourself.** The hook reads `ok` and `status`: a `2xx` is a success, anything else is reported by the section, and a thrown error counts as a failed request. +- **An action you leave out** never shows. One you wire up still shows only where the server's `can*` answer allows it. + +### Controller + +`useContentComments` returns the controller every component reads. The fields you are most likely to use in a custom layout: + +| Name | Description | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `tree` | The loaded comments as a tree: `ContentCommentTreeNode[]`, replies in `children` | +| `items` | The same entries, flat and depth first | +| `status` | `"pending"` before the first page, `"error"` when it failed, then `"success"` | +| `hasMore`, `loadMore()`, `isLoadingMore` | The next page of top-level comments. `loadMore` resolves to `false` when it failed | +| `isRefreshing`, `retry()` | A refetch over data on screen, and retrying a failed first load | +| `replyCount`, `truncated` | Every public comment at any depth, and whether a page held more replies than one answer carries | +| `permissionsOf(id)` | `{ canDelete, canEdit, canHide, canReport }`, all `false` until known or after a failed batch | +| `canComment`, `canReply(node)` | Whether the composer and **Reply** show: signed in, `create` given, under `maxDepth`, not hidden | +| `features` | Which actions you wired up | +| `busy` | The action running right now, `{ action, id }` or `null`. A second action waits for it | +| `remove(id)`, `hide(id, reason)`, `unhide(id)` | Run an action and refresh after success. Resolve to `{ ok: true }` or `{ ok: false, status }`; `status` is `409` when another action was still running and `0` for a network failure | +| `refresh()` | Refreshes the thread, its permissions and `onChanged`'s queries | +| `userId` | The signed-in visitor's id, or `null` | + +### Thread entries + +Each entry of `tree` and `items` is a `ContentCommentNode`: + +| Field | Description | +| ------------------------ | ---------------------------------------------------------------------------- | +| `id`, `parentId` | The comment and the comment it answers, `null` at the top level | +| `depth` | `1` for a comment on the root record, `2` for a reply to it | +| `record` | The public record, or `null` for a placeholder | +| `deleted` | For a placeholder: `{ byAuthor, createdAt }`, with no text and no author | +| `hidden`, `hiddenReason` | Set only for a hidden comment this reader may see: its author or a moderator | + +`buildContentCommentTree(items)` and `countContentCommentReplies(node)` are exported for layouts that work on the flat list. + +## `contentCommentsQueryOptions` + +The thread's infinite query, for warming the first page in a route's `loader`. It takes the same `queryKey`, `thread`, `pageSize` and `record` as the hook and reads the same cache entry: + +```tsx +await context.queryClient + .infiniteQuery(contentCommentsQueryOptions({ queryKey, thread })) + .catch(() => null) +``` + +Its `queryFn` throws when the thread route answers anything but `2xx`. + +## `ContentComments` + +The complete section: the count, the thread, **Load more comments**, the composer or a sign-in link, the actions menu, the delete confirmation and the report dialog. It scrolls to and rings the comment a `#comment-...` link points to, loading more pages until it appears. + +```tsx + +``` + +| Prop | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `comments` | The controller from `useContentComments` | +| `className` | Classes for the `
` | +| `title` | Replaces the "N comments" heading | +| `anchorKey(record)` | What follows `comment-` in each comment's element id. Defaults to the id; return the slug to match slug permalinks | +| `permalink(anchorId)` | The link on each comment's date. Defaults to `#comment-...` on the same page | +| `render.author(record)` | Replaces the author's name | +| `render.body(record)` | Replaces the text | +| `render.meta(record)` | Adds details after the date, such as a badge | +| `render.actions(record)` | Adds buttons next to **Reply** | + +The section's element id is `comments`, so `#comments` links scroll to it. Its messages live in the `core.comments` namespace; add it to the page's route `messages` to load them with the page. + +## Your own layout + +`ContentCommentsRoot` provides the controller to the building blocks below and renders the dialogs they open. It adds the comment messages to the page's, so your own components inside keep their translations. + +```tsx +import { + ContentCommentActions, + ContentCommentComposer, + ContentCommentsComposer, + ContentCommentsLoadMore, + ContentCommentsRoot, + useContentCommentItem, +} from '@vitnode/core/tanstack/comments' + +const MyComment = ({ node }) => { + const item = useContentCommentItem(node) + + if (item.editing) { + return ( + + ) + } + + return ( +
+ {/* your own rendering of node.record */} + +
+ ) +} + +; + {comments.tree.map((node) => ( + + ))} + + + +``` + +| Export | What it is | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ContentCommentsRoot` | Provides `comments` to the components below; takes a `fallback` shown while the messages load | +| `useContentCommentItem(node)` | One comment's state: `permissions` (the server's answers limited to wired actions), `canReply`, `editing`, `startEditing()`, `stopEditing()`, `isReplying`, `setIsReplying`, `isBusy` | +| `ContentCommentActions` | The **More actions** menu with the hide-reason prompt. `children` adds your own menu items | +| `ContentCommentComposer` | The comment form, lazy-loaded: `mode="create"` with `parentId`, or `mode="edit"` with `id` and `editable` | +| `ContentCommentItem` | One comment as the default section draws it | +| `ContentCommentPlaceholder` | The anonymous stand-in for a removed comment | +| `ContentCommentsComposer`, `ContentCommentsLoadMore` | The new-comment card and the load-more button | +| `contentCommentAuthor(record)`, `contentCommentAuthorName(person)` | Read the `author` person and the name to show | +| `useContentCommentErrorToast()` | Shows the section's message for a failed action's status | + +The Blog plugin's `plugins/blog/src/pages/post-comments.tsx` is a complete custom layout: rail connectors, collapsible branches, share buttons and slug permalinks, with every action from these components. + +## Outcomes and errors + +- **Writes.** The composer posts through [`useContentPublicWrite`](/docs/content-engine/public-write-form-api): a refused write keeps the text and shows the reason, and with `editorial` a stale edit shows the version-conflict alert. A success reads the answer's `status` and `approval`: **Comment posted** only when it is public, **saved as a draft** or **waiting for a moderator** otherwise. +- **Actions.** Delete, hide and unhide show **Comment deleted**, **Comment hidden** or **Comment visible again** after the server accepts them. When it refuses, the section shows one message for the cause: signed out, not allowed, gone, busy or unknown. +- **Loading.** A failed first load shows **Try again**; a failed **Load more** keeps the loaded comments and says it failed. + +## Related + +- [Comments](/docs/content-engine/threads): the guided setup. +- [Permissions API](/docs/content-engine/public-permissions-api): `useContentPermissions`, which the hook uses for `permissionsOf`. +- [thread reference](/docs/content-engine/reference#thread): the `thread` option on the content type. diff --git a/apps/web/content/docs/content-engine/container-policies-api.mdx b/apps/web/content/docs/content-engine/container-policies-api.mdx index ffde5b3504..2ebdfb8080 100644 --- a/apps/web/content/docs/content-engine/container-policies-api.mdx +++ b/apps/web/content/docs/content-engine/container-policies-api.mdx @@ -61,7 +61,11 @@ The record's container is always resolved on the server: from its `field.contain | Create with `"moderators"` | `can_edit` for the container, a root role | | Skip `publish` / `edits` approval | `can_approve` for the container, a root role | | Edit any record | `can_edit` for the container, a root role. Independent of `authorEdit` | -| Edit own record | The owner, while `update.own` is on, the record is not hidden and `authorEdit` allows it | +| Edit own record | The owner, while `update.own` is on, the record is not hidden, one of their roles has **Allow editing own content** with its **Time to edit** still open, and `authorEdit` allows it | +| Delete any record | `can_delete` for the container, a root role | +| Delete own record | The owner, when one of their roles has **Allow deleting own content** | +| Hide or unhide any record | `can_hide`, a root role | +| Hide own record | The owner, when one of their roles has **Allow hiding own content**; they may unhide only what they hid themselves | Container scopes are explained in [Container-scoped permissions](/docs/dev/moderation/scoped-permissions#container-scoped-permissions). diff --git a/apps/web/content/docs/content-engine/content-visibility.mdx b/apps/web/content/docs/content-engine/content-visibility.mdx index 4bf4504907..93362aeb81 100644 --- a/apps/web/content/docs/content-engine/content-visibility.mdx +++ b/apps/web/content/docs/content-engine/content-visibility.mdx @@ -57,7 +57,7 @@ CREATE INDEX "blog_posts_hidden_by_idx" ON "blog_posts" ("hiddenBy"); ALTER TABLE "blog_posts" ADD CONSTRAINT "blog_posts_hiddenBy_core_users_id_fkey" FOREIGN KEY ("hiddenBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE; ``` -Existing records stay visible. `hiddenAt` is when the record was hidden and `hiddenBy` is the staff member who hid it. +Existing records stay visible. `hiddenAt` is when the record was hidden and `hiddenBy` is the staff member who hid it. A later migration adds a third nullable column, `hiddenReason` (up to 500 characters), for the optional note left when hiding from the site. @@ -108,7 +108,7 @@ The edit page shows the same icon before the title, an **Unhide** action (in the alt="Blog article editor header with the Draft and Published switch, a Hidden badge, an Unhide button and the note Hidden from the public site, search and the sitemap" /> -Choose **Unhide** in the same menu, or on the edit page, to bring the article back. In the database, hiding sets `blog_posts."hiddenAt"` and `"hiddenBy"`, and unhiding clears both. +Choose **Unhide** in the same menu, or on the edit page, to bring the article back. In the database, hiding sets `blog_posts."hiddenAt"` and `"hiddenBy"`, and unhiding clears both, along with `"hiddenReason"`. Cleaning up after a spam wave? Tick several articles and choose **Visibility → Hide** in the floating bar. The menu counts how many of them are still visible, such as **Hide 2 of 3**, and leaves the rest alone. **Unhide** works the same way. See [Bulk actions](/docs/content-engine/admincp#bulk-actions). diff --git a/apps/web/content/docs/content-engine/database.mdx b/apps/web/content/docs/content-engine/database.mdx index 7c77b23170..8729d5e64f 100644 --- a/apps/web/content/docs/content-engine/database.mdx +++ b/apps/web/content/docs/content-engine/database.mdx @@ -89,6 +89,7 @@ You never declare these. A field with the same name throws an error. | `version` | `integer`, defaults to `1` | `editorial` | | `hiddenAt` | `timestamp`, nullable | `visibility` | | `hiddenBy` | `integer`, nullable, references `core_users.id` with `ON DELETE SET NULL` | `visibility` | +| `hiddenReason` | `varchar(500)`, nullable | `visibility` | Column names are camelCase in SQL, exactly as in TypeScript. Every generated table has row-level security enabled. A field group adds one column per leaf to the main table, so the `syndication` group's `indexable` field becomes `syndicationIndexable`. Column types per field are listed in [Fields](/docs/content-engine/fields). diff --git a/apps/web/content/docs/content-engine/index.mdx b/apps/web/content/docs/content-engine/index.mdx index 0e7cc9436b..0707838fbe 100644 --- a/apps/web/content/docs/content-engine/index.mdx +++ b/apps/web/content/docs/content-engine/index.mdx @@ -52,7 +52,7 @@ Each guide starts from a content type you already have, says what it needs at th | --- | --- | | Public content | [Embedded list](/docs/content-engine/public-lists-embed) | | Member content | [Member writing](/docs/content-engine/public-write), [Create form](/docs/content-engine/public-write-frontend), [Edit form](/docs/content-engine/public-write-edit), [Permissions](/docs/content-engine/public-permissions) | -| Interactions | [Reports](/docs/content-engine/reports), [Comments](/docs/content-engine/threads), [Comment editing](/docs/content-engine/threads-editing), [Replies](/docs/content-engine/threads-frontend) | +| Interactions | [Reports](/docs/content-engine/reports), [Comments](/docs/content-engine/threads), [Comment actions](/docs/content-engine/threads-editing), [Replies](/docs/content-engine/threads-frontend) | | Organization | [Relations](/docs/content-engine/relations), [Advanced fields](/docs/content-engine/relations-advanced), [Containers](/docs/content-engine/containers), [Container pages](/docs/content-engine/container-frontend) | | Moderation | [Approvals](/docs/content-engine/approvals), [Approval notices](/docs/content-engine/approval-notices), [Publishing rules](/docs/content-engine/container-policies), [Visibility](/docs/content-engine/content-visibility) | | Content features | [Rich text](/docs/content-engine/rich-text), [Translations](/docs/content-engine/localization), [Search](/docs/content-engine/search), [SEO](/docs/content-engine/seo) | diff --git a/apps/web/content/docs/content-engine/meta.json b/apps/web/content/docs/content-engine/meta.json index 3697b3e113..228837fc92 100644 --- a/apps/web/content/docs/content-engine/meta.json +++ b/apps/web/content/docs/content-engine/meta.json @@ -64,6 +64,7 @@ "public-write-form-api", "notifications-api", "public-permissions-api", + "comments-api", "container-policies-api", "live-editing-api" ] diff --git a/apps/web/content/docs/content-engine/notifications-api.mdx b/apps/web/content/docs/content-engine/notifications-api.mdx index 2ae7966b10..185163a73f 100644 --- a/apps/web/content/docs/content-engine/notifications-api.mdx +++ b/apps/web/content/docs/content-engine/notifications-api.mdx @@ -43,7 +43,7 @@ It throws `ContentEngineError` when an author field is not a to-one, shared `fie ### Stable ids -`id` is stored with every notification event and every member's preference, so it must never change. The key in `rules` is not stored anywhere: rename it freely. Two rules, in any plugin, cannot share an id. +`id` is stored with every notification event and every member's preference, so it must never change. The key in `rules` is not stored anywhere, so rename it freely. Two rules, in any plugin, cannot share an id. ## `threadReply` preset @@ -64,7 +64,9 @@ New comments and replies in a thread. Runs on the record's [first publication](# Author fields are typed: only to-one, shared `field.user()` names of each content type compile. `content`, `parent` and `root` in `target` are typed rows of their content types. -A member who is both the root's author and the parent's author gets one notification with the `reply` text. `{actor}` is the record's author, or "Someone" when the account is gone. +A member who is both the root's author and the parent's author gets one notification with the `reply` text. The record's author and whoever made it public, such as the moderator who approved it, never receive it. `{actor}` is the record's author, or "Someone" when the account is gone. + +Without `rootAuthorField` the rule notifies replies only, so the root type needs no author. `{title}` reads the root's own row, so a localized title field gives an empty string. Leave `{title}` out of the texts or pass `title`. ## `moderationDecision` preset @@ -134,7 +136,7 @@ deskEditors: { | `data` | `(context) => TData` | Yes | | Data for the type's `schema`, typed from it | | `allowSelf` | `boolean` | No | `false` | Let the actor receive it | -`context` holds `content`, `parent` and `root` (typed rows, reloaded when the notification is sent), `decision` (for `moderationDecision`: `submissionId`, `status`, `kind`, `authorId`, `reviewerId`, `reason`), `actorId`, `trigger`, `contentTypeId` and `c`. `c` is the background worker's context: the database works, but there is no signed-in user. +`context` holds `content`, `parent` and `root` (typed rows, reloaded when the notification is sent), `decision` (for `moderationDecision`: `submissionId`, `status`, `kind`, `authorId`, `reviewerId`, `reason`), `actorId`, `trigger`, `contentTypeId` and `c`. `c` is the background worker's context. The database works, but there is no signed-in user. `recipients` and `data` can run more than once for one operation, because the worker retries after a failure. Keep them free of side effects: read and compute, never send email, call webhooks or write rows. Anything they throw retries the whole operation. @@ -154,7 +156,7 @@ Two different things decide whether something is sent twice: | `firstPublication` | When the record becomes public for the first time: a published create, a publish, a scheduled publish when it fires, or the approval of a create | Never. Unpublishing and republishing, hiding and showing, restoring a revision and editing do not run it | | `moderationDecision` | When a moderator approves or rejects a submission | Once per submission, so each review cycle can notify | -The first publication is recognised inside the publishing transaction: the record's `publishedAt` is `NULL` under the row lock, and VitNode never rewrites it. That state lives on the record itself, not in anyone's inbox, so deleting a notification or the passing of time cannot make a comment notify again. Content published before the rules existed already has a `publishedAt`, so adding rules sends nothing for it, and no migration or backfill is needed. +VitNode recognises the first publication inside the publishing transaction, where the record's `publishedAt` is still `NULL` under the row lock. Once set, VitNode never rewrites it. That state lives on the record itself, not in anyone's inbox, so deleting a notification or the passing of time cannot make a comment notify again. Content published before the rules existed already has a `publishedAt`, so adding rules sends nothing for it, and no migration or backfill is needed. ## Access @@ -174,13 +176,13 @@ A notification that fails the check shows "This notification is no longer availa 3. It resolves recipients, calls your callbacks and publishes every notification of the operation in one transaction. 4. Core fans out to inboxes and emails, checking access again. -A failure retries with backoff up to 5 attempts, then the task is marked `failed` with its error, visible with the other queue tasks. A rule or content type that is no longer registered is skipped with a warning in the logs, never retried forever. Nothing depends on a long-running process: any request or the cron endpoint drives the queue. +A failure retries with backoff up to 5 attempts, then the task is marked `failed` with its error, visible with the other queue tasks. A rule or content type that is no longer registered is skipped with a warning in the logs, never retried forever. Nothing depends on a long-running process. Any request or the cron endpoint drives the queue. ## Cleanup Deleting a record stores a cleanup intent in the same transaction. Once it runs, the record's notifications leave every inbox, and so do notifications about records in its thread when the record was the root, including replies a database cascade removed. A grouped item loses only the deleted contributions and points at the newest one left; it disappears when none is left. Pending emails for those notifications are dropped. -Hiding is not deleting: a hidden record's notifications are only unavailable until it is shown again. A delete that races with a notification being sent is safe either way, because the access check runs again at every step. +Hiding is not deleting. A hidden record's notifications are only unavailable until it is shown again. A delete that races with a notification being sent is safe either way, because the access check runs again at every step. ## `useRevealHashTarget()` diff --git a/apps/web/content/docs/content-engine/notifications-bell.webp b/apps/web/content/docs/content-engine/notifications-bell.webp index cf9c7b4916..24ff575dfc 100644 Binary files a/apps/web/content/docs/content-engine/notifications-bell.webp and b/apps/web/content/docs/content-engine/notifications-bell.webp differ diff --git a/apps/web/content/docs/content-engine/notifications-comment.webp b/apps/web/content/docs/content-engine/notifications-comment.webp index 7a5358b5ce..14cfd93c53 100644 Binary files a/apps/web/content/docs/content-engine/notifications-comment.webp and b/apps/web/content/docs/content-engine/notifications-comment.webp differ diff --git a/apps/web/content/docs/content-engine/notifications.mdx b/apps/web/content/docs/content-engine/notifications.mdx index 48c93a5b09..4538b32897 100644 --- a/apps/web/content/docs/content-engine/notifications.mdx +++ b/apps/web/content/docs/content-engine/notifications.mdx @@ -1,18 +1,18 @@ --- title: Notifications -description: Tell VitNode members about new comments on their articles and replies to their comments with buildContentNotifications. One server-side helper, a few translations and one registration entry reuse the core inbox, preferences and email. +description: Tell VitNode members about replies to their comments, and article authors about new comments, with buildContentNotifications. The notifications are sent by the server, so they work with the shared comment section, a custom layout or the AdminCP. icon: Bell implementation: - objective: Notify members about new comments and replies on a threaded content type, and optionally about moderation decisions, through the core notification system. + objective: Notify members about replies to their comments, and optionally root authors about new comments, on a threaded content type through the core notification system. decisions: - Which threaded content type? Offer content types with the `thread` option and a to-one `field.user()` author field from the project's plugins. - - Who hears about a new comment? The root record's author, the author of the reply being answered, or both. - - 'Should members also get email by default? Recommend `email: "none"`; members can still opt in when the type allows email.' + - Replies only, or comments and replies? Comments need a to-one, shared `field.user()` on the root type. Without one, set up replies only and do not add an author field just for this. + - 'Should members also get email by default? Recommend `email: "none"` with `email: true`, so members can opt in.' - Do the comments go through approval? Only then add a `moderationDecision` rule, and ask where a rejected author should be sent. constraints: - - Configure rules with `buildContentNotifications` in a server-only file and register them in `buildApiPlugin({ contentNotifications })`; do not write event listeners or call `publish()` for these cases. + - Configure rules with `buildContentNotifications` in a server-only file and register them in `buildApiPlugin({ contentNotifications })`; never send notifications from a React component, an event listener or a direct `publish()` call for these cases. - Give every rule a stable, plugin-scoped `id` and API message keys in `src/locales/api`, passed as `buildApiPlugin({ messages })`. - - Link to an anchor that exists on the page, and reveal it on later pages with `useRevealHashTarget`. + - Point `target` at an anchor the comment section renders (`#comment-{id}`, or the slug with `anchorKey`), or at an existing comment permalink route. Do not add a second permalink mechanism. related: - title: Notifications API href: /docs/content-engine/notifications-api @@ -22,27 +22,121 @@ implementation: href: /docs/dev/notifications/notification-types --- +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + import { ImgDocs } from "@/components/fumadocs/img"; import bell from "./notifications-bell.webp"; import comment from "./notifications-comment.webp"; -When someone comments on an article, its author should hear about it, and when someone answers a comment, the comment's author should too. `buildContentNotifications` sets that up for a threaded content type: you name the author fields, the texts and the link, and VitNode sends the notifications through the [core notification system](/docs/dev/notifications), with its bell, inbox, preferences and email. No event listeners, no recipient queries. +When someone answers a comment, its author should hear about it, and when someone comments on an article, the article's author should too. `buildContentNotifications` sets that up for a threaded content type: you name the author fields, the texts and the link, and VitNode sends the notifications through the [core notification system](/docs/dev/notifications), with its bell, inbox, preferences and email. + +The server sends them when a comment becomes public, whoever wrote it and however: the shared comment section, your own layout, the public API or the AdminCP. Your React components send nothing, so there is nothing to change in them. ## Before you begin You need: -- **The newsroom comments with replies**, from [Comments](/docs/content-engine/threads) and [Replies](/docs/content-engine/threads-frontend). The comment type has an `author` field and `thread: { parent: "parent", root: "article" }`. -- **An `author` field on the article type**: `author: field.user()`. Without it there is nobody to tell about a new comment, and the replies part still works. +- **The newsroom comments**, from [Comments](/docs/content-engine/threads). The comment type has `author: field.user()` and `thread: { parent: "parent", root: "article" }`. +- **[Replies](/docs/content-engine/threads-frontend)**, to have replies to notify about. [Comment actions](/docs/content-engine/threads-editing) is not needed. - **Two member accounts**, to see a notification arrive. Nobody is notified about their own comment. +## Choose a setup + +| Setup | Who is told | The article needs | +| --- | --- | --- | +| **A: replies only** | The author of the comment being answered | Nothing | +| **B: comments and replies** | Setup A, plus the article's author about every new comment | A to-one, shared `field.user()` author | + +Pick A when your articles have no single author, for example when staff write them without one or when the author field lists several people. The Blog plugin is in that case: its articles have `authorId: field.user({ multiple: true })`, so Blog uses setup A. Adding an author field only for notifications is never required. + +### Give articles an author (setup B only) + +Skip this step for setup A. + +The newsroom article already has `author: field.user()` if you followed [Create form](/docs/content-engine/public-write). If you did not, add the field to `src/content/article.ts`. This is an excerpt: + +```ts title="plugins/newsroom/src/content/article.ts" + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + // ... + author: field.user(), // [!code ++] + }, +``` + +Then build and migrate from the workspace root. The migration adds a nullable `author` column, so existing articles have no author until you set one: + + + +```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 +``` + + + +An article gets its author in one of two ways: + +- **In the AdminCP.** Open **AdminCP → Newsroom → Articles** and click **Create Article**, or **Edit** on an existing one. Pick a member in **Author**, then save (**Save changes** when editing). The AdminCP shows a user picker for every `field.user()`, so no member forms are involved. +- **From the site.** With [Create form](/docs/content-engine/public-write), `publicWrite.ownership` sets the member who writes the article as its author. + + + + + ### Declare the notifications -Create `src/api/lib/notifications.ts`. It is server-only: only `config.api.ts` imports it, never a page. +Create `src/api/lib/notifications.ts`. It is server-only, so only `config.api.ts` imports it, never a page. + + + + + +```ts title="plugins/newsroom/src/api/lib/notifications.ts" +import { buildContentNotifications } from "@vitnode/core/content/server"; + +import { articleContentType } from "@/content/article"; +import { commentContentType } from "@/content/comment"; + +export const commentNotifications = buildContentNotifications({ + content: commentContentType, + root: articleContentType, + rules: { + replies: { + id: "newsroom.comment_reply", + preset: "threadReply", + recipients: { authorField: "author" }, + messages: { + label: "newsroom.notifications.comment_reply.label", + description: "newsroom.notifications.comment_reply.description", + comment: "newsroom.notifications.comment_reply.comment", + reply: "newsroom.notifications.comment_reply.reply", + }, + target: ({ root, content }) => + `/news/${root.slug}#comment-${content.id}`, + defaults: { inApp: true, email: "none" }, + email: true, + }, + }, +}); +``` + + + + ```ts title="plugins/newsroom/src/api/lib/notifications.ts" import { buildContentNotifications } from "@vitnode/core/content/server"; @@ -73,10 +167,14 @@ export const commentNotifications = buildContentNotifications({ }); ``` -- **`id`** is the notification type's id, stored with every notification and every member's preference. Keep it forever. The key `replies` is only a name in your code, so renaming it changes nothing. -- **`preset: "threadReply"`** covers both cases: the article's author hears about every new comment in their article (`comment` text), and the author of the comment being answered hears about the answer (`reply` text). Someone who is both gets one notification, worded as a reply. -- **`recipients`** names the comment type's author field and the article's author field. Both are type-checked: a field that is not a to-one `field.user()` does not compile. -- **`target`** is where the notification leads. `root` is the article, `content` the new comment, both typed from their content types. + + + + +- **`id`** is the notification type's id, stored with every notification and every member's preference. Keep it forever, and start it with your plugin's name. The key `replies` is only a name in your code. +- **`preset: "threadReply"`** notifies the author of the comment being answered (`reply` text). With `rootAuthorField`, it also tells the article's author about every new comment (`comment` text). `comment` is required either way; in setup A nobody receives it. +- **`recipients`** names author fields. Both are type-checked. A field that is not a to-one, shared `field.user()` does not compile, which is why a multi-author field cannot be the `rootAuthorField`. +- **`target`** is where the notification leads. `root` is the article and `content` the new comment, both typed. - **`defaults`** is what members get before they choose: the bell on, email off. `email: true` still lets them turn email on. @@ -137,7 +235,9 @@ const messages: LocaleMessagesMap = { export default messages; ``` -`{actor}` is the comment's author (or "Someone" for a deleted account) and `{title}` the article's `admin.titleField`. Both are plain text: the inbox never renders them as HTML. +`{actor}` is the comment's author (or "Someone" for a deleted account) and `{title}` the article's `admin.titleField`. Both are plain text, and the inbox never renders them as HTML. For setup A, write the label and description about replies only. + +A localized title field is not on the article's own row, so `{title}` would be empty. The Blog plugin's titles are localized, and its texts leave `{title}` out: `"{actor} replied to your comment"`. @@ -145,84 +245,21 @@ export default messages; ### Make the link land on the comment -The link ends in `#comment-{id}`, so every comment needs that id, and a comment on a later page has to be loaded before the browser can scroll to it. `useRevealHashTarget` does both halves of the job: it loads pages until the comment is on screen, then scrolls to it and focuses it. - -Give each branch an anchor in `src/pages/comment-section.tsx`: - -```tsx title="plugins/newsroom/src/pages/comment-section.tsx" -/** The anchor a notification links to, `#comment-{id}`. */ -export const commentAnchorId = (id: number) => `comment-${id}`; // [!code ++] - -interface CommentThread { - articleId: number; - canReply: boolean; - highlightedId: null | string; // [!code ++] - // ...unchanged -} - -const CommentBranch = ({ node, thread }: { node: CommentNode; thread: CommentThread }) => { - const t = useTranslations("newsroom.comments"); - const [replying, setReplying] = useState(false); - const anchorId = node.record ? commentAnchorId(node.id) : undefined; // [!code ++] - const highlighted = anchorId !== undefined && anchorId === thread.highlightedId; // [!code ++] - - return ( -
  • - {/* ...unchanged */} -
  • - ); -}; -``` +The link ends in `#comment-{id}`, and the shared section from [Comments](/docs/content-engine/threads) gives every comment that element id. When the URL has such a hash, `ContentComments` loads more pages until the comment is on screen, scrolls it into view, focuses it and outlines it. If it is gone or hidden from the reader, the section says **The comment this link points to is no longer available.** There is nothing to add. -Then call the hook in `CommentSection`, after `useCommentPermissions`, and tell the reader when a link points at a comment that is gone: - -```tsx title="plugins/newsroom/src/pages/comment-section.tsx" -import { useRevealHashTarget } from "@vitnode/core/hooks/use-reveal-hash-target"; // [!code ++] - -// In CommentSection: -const anchor = useRevealHashTarget({ // [!code ++] - hasMore: comments.hasNextPage, // [!code ++] - isLoadingMore: comments.isFetchingNextPage, // [!code ++] - loadMore: comments.fetchNextPage, // [!code ++] - prefix: "comment-", // [!code ++] - ready: !comments.isPending, // [!code ++] - renderedCount: items.length, // [!code ++] -}); // [!code ++] - -const thread: CommentThread = { - articleId, - canReply: Boolean(session.data?.user), - highlightedId: anchor.status === "found" ? anchor.targetId : null, // [!code ++] - onEdited, - onPosted, - permissions, -}; +A custom layout built on `ContentCommentsRoot` does the same with `useRevealHashTarget` from `@vitnode/core/hooks/use-reveal-hash-target`, as the [Comments API](/docs/content-engine/comments-api#your-own-layout) describes. If your comment type has a permalink route, point `target` there instead. The Blog plugin uses its existing `/blog/comments/:slug` route, which redirects to `#comment-{slug}` under the article in the reader's language, and answers 404 for a comment that is hidden or gone: -// In the returned JSX, after the list: -{anchor.status === "missing" ? ( // [!code ++] -

    {/* [!code ++] */} - {t("anchor_missing")} {/* [!code ++] */} -

    // [!code ++] -) : null} {/* [!code ++] */} +```ts title="plugins/blog/src/api/lib/notifications.ts" +target: ({ content }) => `/blog/comments/${encodeURIComponent(content.slug)}`, ``` -Add `"anchor_missing": "The comment this link points to is no longer available."` under `newsroom.comments` in `src/locales/en.json`. - ### Rebuild the plugin -Run `pnpm build:plugins` from the workspace root, then restart `pnpm dev`. There is nothing to migrate: notifications use the core tables that already exist. +Run `pnpm build:plugins` from the workspace root, then restart `pnpm dev`. Nothing needs migrating for the notifications themselves, because they use core tables that already exist. @@ -230,32 +267,40 @@ Run `pnpm build:plugins` from the workspace root, then restart `pnpm dev`. There ## Who gets notified -| Situation | Who is notified | -| --- | --- | -| A top-level comment on an article | The article's author, with the `comment` text | -| A reply to a comment | The comment's author (`reply` text) and the article's author (`comment` text) | -| The article's author is also the comment's author | Only that person, once, with the `reply` text | -| Someone comments on their own article or answers their own comment | Nobody: the comment's author is never notified about it | -| The author field is empty, for example a deleted account | Nobody for that relationship; the other one still works | -| A moderator approves a pending comment | As above, but the moderator who approved it is skipped too | +Ada writes the article, Bob and Cleo are members, and Max is a moderator. Recipients are read from the comment, the comment it answers and the article when the notification is sent, never from anyone's session. + +| What happens | Setup A | Setup B | Why | +| --- | --- | --- | --- | +| Bob comments on Ada's article | Nobody | Ada (`comment`) | Ada is the article's author | +| Ada comments on her own article | Nobody | Nobody | A comment's author is never notified about it | +| Cleo replies to Bob's comment | Bob (`reply`) | Bob (`reply`), Ada (`comment`) | Bob wrote the parent, Ada the article | +| Ada replies to Bob's comment on her article | Bob (`reply`) | Bob (`reply`) | Ada wrote the reply, so she is left out | +| Cleo replies to Ada's comment on Ada's article | Ada (`reply`) | Ada once (`reply`) | Someone who is both authors gets one notification, worded as a reply | +| Bob replies to his own comment on Ada's article | Nobody | Ada (`comment`) | Bob is left out; Ada still hears about a new comment in her article | +| The article or the parent comment has no author | Nobody for that one | Nobody for that one | An empty author field, including a deleted account, is skipped; the other recipient still gets theirs | +| The new comment has no author | As above | As above | Recipients still get it, and `{actor}` reads "Someone" | +| Max approves Bob's pending comment | As if Bob posted it | As if Bob posted it, minus Max | Bob is the actor shown. Whoever made the comment public is skipped, so Max hears nothing even when he wrote the article or the parent | -Recipients are user ids read from the records when the notification is sent, never from the session of whoever acted. Core then drops deleted accounts and members who turned the type off. +Core then drops deleted accounts, members who turned the type off and anyone who may no longer see the comment. ## When it fires -A comment notifies **once, the first time it becomes public**: posted straight to the public thread, approved by a moderator, or published by a schedule when the schedule fires. VitNode tells that moment apart by the record's `publishedAt`, which is set once and never rewritten. +A comment notifies **once, the first time it becomes public**. VitNode tells that moment apart by the comment's `publishedAt`, which is set once and never rewritten. A notification already in an inbox is checked again each time the inbox loads and before each email, so it follows what happens to the comment later. | What happens to the comment | Notification | | --- | --- | -| Posted with `create.status: "published"` | Yes | -| Posted as a draft, or waiting for approval | No, nobody can read it yet | -| A moderator approves it | Yes, once | -| Edited, autosaved, or an edit approved | No | -| Hidden, then shown again | No new one. While it is hidden, the existing notification shows "This notification is no longer available" | -| Unpublished, then published again | No new one | -| Deleted, or its article deleted | The notification is removed from inboxes | - -Comments that were already public before you added the rules never notify: they already have a `publishedAt`, so nothing is sent after a deployment. +| Posted with `create.status: "published"` | Sent a moment later | +| Saved as a draft | Nothing yet. Sent when the draft is first published | +| Waiting for approval, then approved | Sent once, at approval | +| Rejected | None about the comment. A [`moderationDecision` rule](#tell-authors-about-moderation-decisions) can tell its author | +| Edited, or an edit approved | No new one | +| Hidden, then shown again | Shows "This notification is no longer available." while hidden, then comes back. A comment hidden before the notification went out is never announced | +| Unpublished, then published again | Unavailable meanwhile, then back. No new one | +| Deleted without replies | Removed from inboxes | +| Deleted while it has replies | It stays as a placeholder in the thread, and its notification is unavailable. Notifications about its replies stay | +| Its article unpublished, hidden or deleted | Unavailable while the article is not public; removed with a deleted article | + +Comments that were already public before you added the rules never notify, so deploying them doesn't bury every author under a year of old news. The notification is stored in the same database transaction as the comment, and a background task sends it a moment after the response. If the comment's transaction rolls back, nothing is sent. If the task fails, the queue retries it without ever creating a second notification. [Notifications API](/docs/content-engine/notifications-api#delivery-and-retries) has the details. @@ -283,21 +328,23 @@ Each decision notifies the submitter once, so a resubmitted comment that is reje ## Preferences and email -Each rule is an ordinary notification type. Members find **Comments and replies** in their notification preferences and can turn the bell or email on and off. Administrators can change the defaults or switch the type off on the [AdminCP notifications page](/docs/dev/notifications/admincp). Email needs an email adapter and `email: true` (or a `defaults.email` other than `"none"`); immediate emails and daily or weekly digests then work like every other type. +Each rule is an ordinary notification type. Members find **Comments and replies** under **Settings → Notifications** and can turn the notification list, push or email on and off for it. Administrators can change the defaults or switch the type off on the [AdminCP notifications page](/docs/dev/notifications/admincp). Email needs an email adapter and `email: true` (or a `defaults.email` other than `"none"`); immediate emails and daily or weekly digests then work like every other type. ## Check the result -1. Sign in as member A and publish an article. -2. In another browser, sign in as member B and comment on it. Within a few seconds, A's bell shows a new notification: +You need two members, Ada and Grace. For setup B, first make Ada the author of a published article, as in [Give articles an author](#give-articles-an-author-setup-b-only). For setup A, any published article works. + +1. Sign in as Grace in another browser and comment on the article. With setup B, Ada's bell shows a new notification within a few seconds: - + -3. As A, open the notification. The article opens with B's comment highlighted, even when it is on the second page of comments: +2. As Ada, open the notification. The article opens with Grace's comment highlighted, even when it is on a later page of comments: - + -4. As A, reply to B's comment. B gets "replied to your comment"; A gets nothing. -5. As a moderator, hide B's comment in the AdminCP. A's notification now says it is no longer available; show the comment again and the notification comes back. +3. As Ada, reply to Grace's comment. Grace gets "replied to your comment" and Ada gets nothing. Open it as Grace, and the reply is highlighted below Grace's comment. +4. As Grace, open **Settings → Notifications**, expand **Comments and replies** and turn **Notification list** off. Have Ada reply again. Nothing new arrives. Turn it back on. +5. As a moderator, hide Grace's comment. Ada's notification now says it is no longer available. Show the comment again and the notification comes back. 6. Delete the article. Both notifications disappear from the inboxes. ## Related diff --git a/apps/web/content/docs/content-engine/public-permissions-api.mdx b/apps/web/content/docs/content-engine/public-permissions-api.mdx index 63f62a01c2..d7e6e3a5ff 100644 --- a/apps/web/content/docs/content-engine/public-permissions-api.mdx +++ b/apps/web/content/docs/content-engine/public-permissions-api.mdx @@ -8,7 +8,7 @@ This page is the contract behind [Permissions](/docs/content-engine/public-permi ## When the route exists -The Content Engine mounts `GET /permissions` next to the [public read routes](/docs/content-engine/public-api) when the content type has [`publicWrite.update`](/docs/content-engine/public-write) or [`reports`](/docs/content-engine/reports). Without either, there is nothing to answer and the [fetcher](/docs/dev/fetcher) refuses the path at compile time. +The Content Engine mounts `GET /permissions` next to the [public read routes](/docs/content-engine/public-api) when the content type has [`publicWrite`](/docs/content-engine/public-write), [`reports`](/docs/content-engine/reports) or [`visibility`](/docs/content-engine/content-visibility). Without any of them, there is nothing to answer and the [fetcher](/docs/dev/fetcher) refuses the path at compile time. ```http title="Request" GET /api/newsroom/content/news/permissions?ids=1,2,15 diff --git a/apps/web/content/docs/content-engine/public-permissions.mdx b/apps/web/content/docs/content-engine/public-permissions.mdx index 30d273a9a6..7d73cd011a 100644 --- a/apps/web/content/docs/content-engine/public-permissions.mdx +++ b/apps/web/content/docs/content-engine/public-permissions.mdx @@ -5,7 +5,7 @@ icon: KeyRound implementation: objective: Show action buttons, such as Edit, only to visitors the Content Engine allows. decisions: - - Which items get the buttons? Offer content types with `publicWrite.update` or `reports`. + - Which items get the buttons? Offer content types with `publicWrite`, `reports` or `visibility`. - Which pages show them? Offer the record pages and lists that render those items. constraints: - Ask `GET /{publicApi.path}/permissions` through `useContentPermissions`; never compare author ids or roles in the browser. diff --git a/apps/web/content/docs/content-engine/public-write-api.mdx b/apps/web/content/docs/content-engine/public-write-api.mdx index d38f0d311f..c35f0fb3a0 100644 --- a/apps/web/content/docs/content-engine/public-write-api.mdx +++ b/apps/web/content/docs/content-engine/public-write-api.mdx @@ -74,7 +74,7 @@ The routes live in the public module, under `/api/{pluginId}/content/{publicApi. | `POST /` | `create` | `{ values }` | `201` with the record | | `GET /{id}/edit` | `update` | | `200` with the record, drafts included, `Cache-Control: private, no-store` | | `PATCH /{id}` | `update` | `{ values, expectedVersion }` | `200` with the record | -| `GET /permissions?ids=1,2` | `update` or `reports` | | `200` with `{ items: [{ id, canEdit, canReport }] }`, `Cache-Control: private, no-store` | +| `GET /permissions?ids=1,2` | `publicWrite`, `reports` or `visibility` | | `200` with `{ items: [{ id, canDelete, canEdit, canHide, canReport }] }`, `Cache-Control: private, no-store` | `GET /{id}/edit` and `PATCH /{id}` answer only for the owner and for moderators. There is no public delete, publish or hide route. diff --git a/apps/web/content/docs/content-engine/reports.mdx b/apps/web/content/docs/content-engine/reports.mdx index 8331354621..1bf2dcc59f 100644 --- a/apps/web/content/docs/content-engine/reports.mdx +++ b/apps/web/content/docs/content-engine/reports.mdx @@ -229,6 +229,16 @@ The button is only a hint. The report route checks again: ## Report from a list or a comment - **Every row of a list.** Call the permissions hook once with every id on the page, and point each button at its row's title with `describedBy`, so a screen reader hears which record each **Report** belongs to. The newsroom plugin's letters list does this in `plugins/newsroom/src/pages/letters-page.tsx`. -- **Comments.** Add `reports: true` to the [comment type](/docs/content-engine/threads), read `canReport` from its own permissions hook, and pass `contentTarget(manifest.id, commentContentType, comment.id)`. `snapshotFields: ["body"]` gives moderators the comment's text, since a comment has no title. +- **Comments.** The shared comment section reports through this same dialog: add `reports` to the comment type and a `report` callback to `useContentComments`, as [Comment actions](/docs/content-engine/threads-editing#connect-reporting) shows. A comment has no title, so point `titleField` at its rich-text body: the report is titled with the comment's first 120 characters, and `snapshotFields: ["body"]` keeps the whole text. -Next, let readers answer an article with [Comments](/docs/content-engine/threads). + ```ts + reports: { titleField: "body", snapshotFields: ["body"], selfReport: true }, + ``` + + `selfReport: true` lets authors report their own comments too, for when they want a moderator to look at something they wrote. Without it the Report button skips the author's own records, which is the default for every content type. + + ModCP links each report to the record's public URL, from `delivery.path` or `search.pathTemplate`. A comment has no page of its own, so give it a permalink that redirects to its place in the thread, as [Link reports to the comment](/docs/content-engine/threads-editing#link-reports-to-the-comment) does for the Newsroom and `delivery: { path: "/blog/comments/:slug" }` does for the Blog. + +## Related + +- [Comment actions](/docs/content-engine/threads-editing): report, hide and delete comments from the shared comment section. diff --git a/apps/web/content/docs/content-engine/search.mdx b/apps/web/content/docs/content-engine/search.mdx index 2059e6e1d4..19a8fcf8ce 100644 --- a/apps/web/content/docs/content-engine/search.mdx +++ b/apps/web/content/docs/content-engine/search.mdx @@ -189,7 +189,7 @@ The result is headed by the related record's own `search.titleField`, here the t - It points at exactly one record of another content type: to-one, not nullable and not `self: true`. - It is in `publicApi.fields`, like every other indexed field. - The target has a `search` block of its own, because that is where the title comes from. Without one there is no heading to borrow, and the record stays out of the index. -- The content type is not [localized](#localized-content-types), because one related title would have to serve every language. +- The content type is not [localized](#localized-content-types), because one related title would have to serve every language. The *target* may be localized: its title and link then come from its default-language translation. A broken rule throws when the definition loads, for example `search.titleField names the nullable relation "topic". A search result needs a heading, so the relation must not be nullable.` @@ -201,6 +201,17 @@ The two records then stay in step: Records saved before you turned this on need a **Reindex** of the collection, or **Rebuild index** in **Debug Panel → Search** (`/admin/core/debug/search`). +### Comments get this for free + +A content type with [`thread`](/docs/content-engine/threads) and no `search` block is indexed this way automatically, so comments land in `/search`, `/discover` and profile timelines as "Maciej commented on *Hello world*" without a line of config. The engine fills in: + +- `titleField`: the thread's `root` relation. +- `contentFields`: every text and rich text field in `publicApi.fields`, except the slug. +- `authorField`: the `publicWrite` owner, when it's a `field.user()`. +- `pathTemplate`: the comment's `delivery.path`, with `:slug` as `{slug}`. + +It only kicks in when all of that exists and the root type is searchable itself. Otherwise the comments stay out of search, as before. Write your own `search` block to change any of it, or `search: false` to keep comments out. + ## Say where a record lives A feed reads better as activity than as a list of headings: "aXen commented on Tabs or spaces", "aXen posted Tabs or spaces in General". For that, a document needs to know what it belongs to. `search.context` names that field: diff --git a/apps/web/content/docs/content-engine/threads-comments.webp b/apps/web/content/docs/content-engine/threads-comments.webp index 07f20dd42e..f501e7998b 100644 Binary files a/apps/web/content/docs/content-engine/threads-comments.webp and b/apps/web/content/docs/content-engine/threads-comments.webp differ diff --git a/apps/web/content/docs/content-engine/threads-editing-form.webp b/apps/web/content/docs/content-engine/threads-editing-form.webp deleted file mode 100644 index 0c21691c34..0000000000 Binary files a/apps/web/content/docs/content-engine/threads-editing-form.webp and /dev/null differ diff --git a/apps/web/content/docs/content-engine/threads-editing.mdx b/apps/web/content/docs/content-engine/threads-editing.mdx index 329b1b86f7..8b41417aac 100644 --- a/apps/web/content/docs/content-engine/threads-editing.mdx +++ b/apps/web/content/docs/content-engine/threads-editing.mdx @@ -1,683 +1,526 @@ --- -title: Comment editing -description: Let VitNode Content Engine comment authors fix their comments. Ask useContentPermissions who may edit, load the editable text, save it with PATCH and refresh the thread, with the concurrency behavior that applies without editorial. +title: Comment actions +description: Let VitNode Content Engine comment authors and moderators edit, delete, hide, restore and report comments in the shared comment section. Each action takes one content-type option and one typed callback, and the server decides who may do what. icon: MessageSquareText implementation: - objective: Let members edit their own comments in an existing comment section. + objective: Add edit, delete, hide and restore, and report to an existing shared comment section. decisions: - - Should two saves of the same comment be checked against each other? That needs `editorial` on the comment type; without it, the last save wins. + - Which actions should the section offer? Offer edit, delete, hide and restore, and report, and add only the chosen ones. + - May members delete or hide their own comments? Both are off by default; they are role settings in the AdminCP, not code. + - Should authors be able to report their own comments? Recommend no; `reports.selfReport` turns it on. constraints: - - Show Edit only when `useContentPermissions` answers `canEdit`, with one hook call for every comment on screen. - - Load the text with `GET /{id}/edit` before editing, save with `PATCH /{id}` through `useContentPublicWrite`, then invalidate the thread query. + - 'Enable the matching content-type options first: `publicWrite.update` for edit, `visibility` for hide, `reports` for report. Delete needs only `publicWrite`.' + - Add each action as one inline `fetcher` callback to the existing `useContentComments` call, and add `permissions`; never decide visibility of an action from author ids. + - Give comments a permalink (`delivery.path` plus a redirect page) so ModCP links open the reported comment. related: - title: Comments href: /docs/content-engine/threads - - title: Replies - href: /docs/content-engine/threads-frontend - - title: Permissions API - href: /docs/content-engine/public-permissions-api + - title: Comments API + href: /docs/content-engine/comments-api + - title: Roles + href: /docs/dev/working-with-users/roles#editing-own-content --- -import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; import { ImgDocs } from "@/components/fumadocs/img"; -import editing from "./threads-editing-form.webp"; +import deleted from "./comment-actions-delete.webp"; +import editing from "./comment-actions-edit.webp"; +import hidden from "./comment-actions-hidden.webp"; +import menu from "./comment-actions-menu.webp"; +import modcp from "./comment-actions-modcp.webp"; -This page adds **Edit** to the comments a visitor may change, opens an editor in place, and refreshes the thread after a save. Authors edit their own comments; moderators with `can_edit` edit anyone's. +This page puts a **More actions** menu on each comment of the section from [Comments](/docs/content-engine/threads): **Edit**, **Delete**, **Hide** or **Unhide**, and **Report**. Each action is a content-type option plus one callback in `article-comments.tsx`. The menu shows an action only where you wired it **and** the server says this visitor may use it on this comment. -This continues the working comment section from [Comments](/docs/content-engine/threads). Its comment type already has `update: { fields: ["body"] }`, so `PATCH /comments/{id}` and `GET /comments/{id}/edit` exist. +| Action | Required configuration | Server permission | Callback and shared UI | +| --- | --- | --- | --- | +| Edit | `publicWrite.update` | `canEdit`: the author while their role's edit window is open, or a moderator with **Edit** | `edit: { load, save }` adds an inline editor and a conflict alert with revisions | +| Delete | `publicWrite` | `canDelete`: the author when their role allows it, or a moderator with **Delete** | `delete` adds a confirmation dialog, and a placeholder when the comment has replies | +| Hide and restore | `visibility` | `canHide`: a moderator with **Hide**, or the author when their role allows it | `visibility: { hide, unhide }` adds a reason prompt and the hidden state | +| Report | `reports` | `canReport`: signed-in visitors on other people's comments | `report` opens the core report dialog, which files to ModCP | + +## Before you begin + +- The working comment section from [Comments](/docs/content-engine/threads). +- Two member accounts, and an account that moderates. A root administrator holds every moderator permission. For anyone else, add them in **AdminCP → Staff → Moderators**, as [Configure moderators](/docs/dev/moderation/configure-moderators) shows. -### Ask who may edit +### Turn on the options -Create `src/pages/comment-permissions.ts`. This is the complete file, like the article's in [Permissions](/docs/content-engine/public-permissions): +Add `visibility`, `reports` and `update` to the comment type in `src/content/comment.ts`. This is an excerpt; keep the rest of the file: -```ts title="plugins/newsroom/src/pages/comment-permissions.ts" -import { useContentPermissions } from "@vitnode/core/tanstack/content"; -import { fetcher } from "@vitnode/core/tanstack/fetcher"; +```ts title="plugins/newsroom/src/content/comment.ts" + publication: true, + visibility: true, // [!code ++] + reports: { titleField: "body", snapshotFields: ["body"] }, // [!code ++] -import { manifest } from "@/manifest.gen"; + publicWrite: { + ownership: { field: "author" }, + create: { fields: ["body", "article", "parent"], status: "published" }, + update: { fields: ["body"] }, // [!code ++] + }, +``` -/** What the visitor may do with each comment on screen: `canEdit`, `canReport`. */ -export const useCommentPermissions = (ids: readonly number[]) => - useContentPermissions({ - ids, - queryKey: [manifest.id, "comments"], - fetch: async request => { - const res = await fetcher({ - plugin: manifest.id, - method: "get", - module: "content/comments", - path: "/permissions", - args: { query: { ids: request.join(",") } }, - }); - if (res.status !== 200) throw new Error("Loading permissions failed."); +- **`visibility`** adds `POST /{id}/hide` and `/unhide`, and the columns that remember who hid a comment and why. +- **`reports`** adds reporting. `titleField: "body"` gives moderators the first line of the comment as the report's title, so comments need no title field of their own. `snapshotFields` keeps a copy of the text as it was when reported. +- **`update`** adds `GET /{id}/edit` and `PATCH /{id}`. Only `body` is editable; a comment never moves to another article or parent. +- **`GET /permissions`**, which the menu asks, already exists, because `publicWrite` mounts it. - return await res.json(); - }, - }); +Add the moderation labels next to the other `newsroom:comment` labels in `src/locales/en.json`: + +```json title="plugins/newsroom/src/locales/en.json" +{ + "newsroom:comment:can_view_reports": "View comment reports", + "newsroom:comment:can_manage_reports": "Claim, assign and resolve comment reports", + "newsroom:comment:can_view_hidden": "Open hidden comments" +} +``` + +Then build and migrate from the workspace root: + + + +```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 ``` -`GET /comments/permissions` answers `canEdit` per comment for the visitor who asks. It is `true` for the author while the rules allow it, and for a moderator with `can_edit`. `queryKey` starts with the comments' prefix, so invalidating `[manifest.id, "comments"]` refreshes the answers too. + -### Write the editor - -Create `src/pages/comment-editor.tsx`. This is the complete file. It edits one comment's body and reports whether the save waits for a moderator: - -```tsx title="plugins/newsroom/src/pages/comment-editor.tsx" -import { - AutoForm, - type AutoFormOnSubmit, - AutoFormSubmitButton, -} from "@vitnode/core/components/form/auto-form"; -import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor"; -import { Button } from "@vitnode/core/components/ui/button"; -import { - isRichTextEmpty, - type RichTextDocument, - zodRichTextDocument, -} from "@vitnode/core/content"; -import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; -import { fetcher } from "@vitnode/core/tanstack/fetcher"; -import { useTranslations } from "use-intl"; -import { z } from "zod"; - -import { manifest } from "@/manifest.gen"; +### Ask who may do what -export interface CommentEditorProps { - /** The comment's latest text, from `GET /comments/{id}/edit`. */ - body: RichTextDocument; - commentId: number; - label: string; - onCancel: () => void; - /** Called after a save, with whether it waits for a moderator. */ - onSaved: (pending: boolean) => Promise; -} +Add `permissions` to the `useContentComments` call in `src/pages/article-comments.tsx`. This is an excerpt; the following steps add to the same call: -const createSchema = (body: RichTextDocument, required: string) => - z.object({ - body: zodRichTextDocument - .refine(doc => !isRichTextEmpty(doc), required) - .default(body), +```tsx title="plugins/newsroom/src/pages/article-comments.tsx" + const comments = useContentComments({ + ...articleCommentsThread(articleId), + create: async ({ body, parentId }) => /* unchanged */, + // [!code ++:9] + permissions: async ids => + await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/article-comments", + path: "/permissions", + args: { query: { ids: ids.join(",") } }, + }), }); +``` + +The section asks once for every comment on screen, in batches of 50, and again for the comments **Load more comments** adds. A comment whose answer has not arrived, or whose batch failed, shows no actions. The answers belong to the signed-in visitor, so signing in or out asks again. + +The server answers from settings, not from your code: + +- **Authors** edit their own comments while their role's **Time to edit** runs, if **Allow editing own content** is on (it is, by default). **Allow deleting own content** and **Allow hiding own content** are off by default. Turning on `visibility` does not turn them on. [Roles](/docs/dev/working-with-users/roles#editing-own-content) explains all three. +- **Moderators** act on anyone's comments, at any age, with the comment type's permissions in **AdminCP → Staff → Moderators**: **Edit comments**, **Delete comments** and **Hide and unhide comments**. +- **Reporting** is open to every signed-in visitor on other people's comments. Add `selfReport: true` to `reports` to let authors report their own. + + + + -const CommentEditor = ({ - body, - commentId, - label, - onCancel, - onSaved, -}: CommentEditorProps) => { - const t = useTranslations("newsroom.comments.form"); - const write = useContentPublicWrite({ fields: ["body"] }); - - const onSubmit: AutoFormOnSubmit> = async ( - values, - form, - ) => { - const saved = await write.submit( - form, - async () => +### Connect edit + +```tsx title="plugins/newsroom/src/pages/article-comments.tsx" + // [!code ++:20] + edit: { + load: async id => + await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/article-comments", + path: "/{id}/edit", + args: { params: { id } }, + }), + // Comments have no editorial revisions, so the last save wins. + save: async (id, { body }) => await fetcher({ plugin: manifest.id, method: "patch", - module: "content/comments", + module: "content/article-comments", path: "/{id}", - args: { params: { id: commentId }, body: { values } }, + args: { params: { id }, body: { values: { body } } }, }), - ); - if (!saved) return; - - await onSaved(saved.approval?.status === "pending"); - }; - - return ( - ( - - ), - }, - ]} - formSchema={createSchema(body, t("required"))} - layout={fields => <>{fields.body}} - onSubmit={onSubmit} - > -
    - {t("save")} - -
    -
    - ); -}; - -export default CommentEditor; + }, +``` + +**Edit** loads the editable text first and opens the editor in place of the comment. After **Save**, the thread and the form's defaults refresh. If the author's edit waits for a moderator, the editor opens on that proposed text, and saving says it is waiting rather than live. + +Newsroom comments have no `editorial`, so two saves of the same comment both succeed and the last one wins. To refuse a stale save instead, add `editorial: true` to the comment type, migrate, and send the version: + +```tsx +save: async (id, { body, expectedVersion }) => + await fetcher({ + plugin: manifest.id, + method: "patch", + module: "content/article-comments", + path: "/{id}", + args: { params: { id }, body: { values: { body }, expectedVersion } }, + }), ``` -- **The request** sends only `body` to `PATCH /comments/{id}`. Members can never move a comment, because `article` and `parent` are not in `update.fields`. -- **`write.submit`** shows a refusal as a toast and returns `null`, so the editor stays open with the text. -- **`onSaved(pending)`** is `true` only when the comment lives in a container that holds edits for approval. For newsroom comments it is always `false`. +A stale save then answers `409`, and the editor shows **Someone else saved in the meantime** with **Keep my version** and **Load latest version**. The Blog plugin's comments work this way.
    -### Show Edit and load the comment +### Connect delete -Edit `src/pages/comment-section.tsx`. Add the imports, load the editor lazily next to the composer, and add a `CommentItem` component above `CommentSection`. These are excerpts: +```tsx title="plugins/newsroom/src/pages/article-comments.tsx" + // [!code ++:8] + delete: async id => + await fetcher({ + plugin: manifest.id, + method: "post", + module: "content/article-comments", + path: "/{id}/delete", + args: { params: { id } }, + }), +``` -```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" -import { type RichTextDocument } from "@vitnode/core/content"; // [!code ++] -import { fetcher } from "@vitnode/core/tanstack/fetcher"; // [!code ++] +**Delete** asks for confirmation first. A comment nobody answered is removed. A comment with replies stays in the thread as an anonymous **[deleted]** placeholder with no text and no author, so its replies keep their place. The placeholder reads "Comment deleted by user" or "Comment removed by a moderator". Deleting the last reply under a placeholder removes the placeholder too. A deleted comment cannot be restored. -import { manifest } from "@/manifest.gen"; // [!code ++] + -import type { CommentEditorProps } from "./comment-editor"; // [!code ++] + -import { useCommentPermissions } from "./comment-permissions"; // [!code ++] +### Connect hide and restore -const CommentComposer = lazy(async () => await import("./comment-composer")); -const CommentEditor = lazy(async () => await import("./comment-editor")); // [!code ++] +```tsx title="plugins/newsroom/src/pages/article-comments.tsx" + // [!code ++:18] + visibility: { + hide: async (id, { reason }) => + await fetcher({ + plugin: manifest.id, + method: "post", + module: "content/article-comments", + path: "/{id}/hide", + args: { params: { id }, body: reason ? { reason } : {} }, + }), + unhide: async id => + await fetcher({ + plugin: manifest.id, + method: "post", + module: "content/article-comments", + path: "/{id}/unhide", + args: { params: { id } }, + }), + }, ``` -```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" -/** A comment, with Edit for the visitors who may use it. */ -const CommentItem = ({ - canEdit, - comment, - onSaved, -}: { - canEdit: boolean; - comment: ArticleComment; - onSaved: CommentEditorProps["onSaved"]; -}) => { - const t = useTranslations("newsroom.comments"); - const tWrite = useTranslations("core.global.content_write"); - const [editing, setEditing] = useState(null); - const [isOpening, setIsOpening] = useState(false); - - // Opens the editor on the comment's latest text, not on the cached copy. - const openEditor = async () => { - setIsOpening(true); - try { - const res = await fetcher({ - plugin: manifest.id, - method: "get", - module: "content/comments", - path: "/{id}/edit", - args: { params: { id: comment.id } }, - }); - if (res.status !== 200) { - const reason = - res.status === 401 - ? "unauthenticated" - : res.status === 403 - ? "forbidden" - : res.status === 404 - ? "not_found" - : "unknown"; - toast.error(tWrite("title"), { description: tWrite(reason) }); - - return; - } +**Hide** asks for an optional reason of up to 500 characters. A hidden comment disappears for everyone else, but stays visible, marked **Hidden** with its reason, to its author and to moderators, who get **Unhide**. Readers who can't see it get the same placeholder as a deleted comment when it has replies, and nothing when it has none. Hiding is not deleting. **Unhide** brings the comment back unchanged. It is also not unpublishing, which is a staff action in the AdminCP. - setEditing((await res.json()).values.body); - } finally { - setIsOpening(false); - } - }; - - if (editing) { - return ( -
    -

    - {comment.author?.name ?? t("someone")} -

    - }> - { - setEditing(null); - }} - onSaved={async pending => { - setEditing(null); - await onSaved(pending); - }} - /> - -
    - ); - } - - return ( -
    - - {canEdit ? ( - - ) : null} -
    - ); -}; +
    + + + +### Connect reporting + +```tsx title="plugins/newsroom/src/pages/article-comments.tsx" +import { contentTarget } from "@vitnode/core/content"; // [!code ++] + +import { commentContentType } from "@/content/comment"; // [!code ++] + + report: id => contentTarget(manifest.id, commentContentType, id), // [!code ++] ``` -- **Edit** first loads `GET /comments/{id}/edit`, so the editor opens on the latest saved text, not on the copy the thread cached. The button shows a spinner while it loads, so a second click cannot open it twice. -- A refusal, such as a session that expired in the meantime, becomes a toast with core's message, and the comment stays as it was. -- While editing, the comment's text is replaced by the editor, with **Save** and **Cancel**. +**Report** opens the core report dialog, where the visitor picks a reason. A guest who reaches it is asked to sign in first and comes back to the article. Reports land in ModCP with the comment's first line as their title. -### Save and refresh the section +### Link reports to the comment -In `CommentSection`, ask for the permissions of every comment on screen, add a handler for saved edits, and render `CommentItem` instead of `CommentBody`. These are excerpts: +ModCP links each report to its content's public URL, and a comment has none yet. Give it a permalink that redirects to the comment under its article. -```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" - const items = useMemo( - () => pages?.flatMap(page => page.items) ?? [], - [pages], - ); - // [!code ++:4] - // One request for every comment on screen, batched by the hook. - const permissions = useCommentPermissions( - items.flatMap(item => (item.record ? [item.id] : [])), - ); - - // [!code ++:12] - const onEdited = async (pending: boolean) => { - if (pending) { - toast.info(t("edit_pending.title"), { - description: t("edit_pending.desc"), - }); - } else { - toast.success(t("edited.title"), { description: t("edited.desc") }); - } - await queryClient.invalidateQueries({ - queryKey: commentsQueryKey(articleId), - }); - }; +Add `delivery` to the comment type. This is an excerpt: + +```ts title="plugins/newsroom/src/content/comment.ts" + delivery: { path: "/news/comments/:slug" }, // [!code ++] + + thread: { parent: "parent", root: "article", maxDepth: 1 }, ``` -```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" - {items.map(item => - item.record ? ( -
  • - {/* [!code ++:5] */} - -
  • - ) : null, - )} +Delivery claims the type's `publicApi.path` across the whole site. That's why this guide picked `article-comments`. The Blog plugin already delivers `comments`, and two plugins can't share a path. + +The redirect needs the article's slug, and a comment only knows its article's id. Add a small public route that answers it. Create `src/api/modules/articles/articles.module.ts`: + +```ts title="plugins/newsroom/src/api/modules/articles/articles.module.ts" +import { z } from "@hono/zod-openapi"; +import { setPageCacheTags } from "@vitnode/core/api/lib/page-cache"; +import { contentPublicListTag } from "@vitnode/core/content"; +import { HTTPException } from "hono/http-exception"; + +import { buildModule, buildRoute } from "@/api/builders"; +import { articleContent } from "@/database/articles"; + +const articleSlugRoute = buildRoute({ + route: { + method: "get", + path: "/{id}/slug", + description: + "The slug of one public article, for turning a comment permalink into the comment under its article", + request: { + params: z.object({ + id: z.coerce.number().int().positive().openapi({ example: 1 }), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ id: z.number(), slug: z.string() }), + }, + }, + description: "The article's slug", + }, + 404: { description: "No public article with this id" }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const service = articleContent.publicService?.(c); + if (!service) { + throw new HTTPException(500, { message: "Articles have no public API." }); + } + + const article = await service.findById(id); + if (!article) throw new HTTPException(404, { message: "Article not found." }); + + setPageCacheTags(c, [contentPublicListTag(articleContent.definition.id)]); + + return c.json({ id: article.id, slug: article.slug }, 200); + }, +}); + +export const articlesModule = buildModule({ + name: "articles", + routes: [articleSlugRoute], +}); ``` -- **`useCommentPermissions`** gets every comment with a record in one call. It sends at most 50 ids per request and keeps earlier answers when **Load more comments** adds ids, as [Permissions API](/docs/content-engine/public-permissions-api#batches) describes. `of(id)` is all `false` until the answer arrives, so **Edit** appears a moment after the comments. -- **`onEdited`** invalidates the article's comment query, which refetches every loaded page, so the new text shows in place. +Register it in `src/config.api.ts`. This is an excerpt: -
    +```ts title="plugins/newsroom/src/config.api.ts" +import { articlesModule } from "./api/modules/articles/articles.module"; // [!code ++] - + modules: [ + helloModule, + adminModule, + articlesModule, // [!code ++] +``` -### Add the labels +The redirect page uses TanStack Query and TanStack Router. Skip this if the plugin already has both; otherwise run this from the workspace root: -Add these keys to the `comments` group in `src/locales/en.json`: + -```json title="plugins/newsroom/src/locales/en.json" -{ - "newsroom": { - "comments": { - "form": { "edit_label": "Edit your comment", "save": "Save", "cancel": "Cancel" }, - "edit": "Edit", - "edited": { "title": "Comment saved", "desc": "Your changes are live." }, - "edit_pending": { - "title": "Your changes are waiting for a moderator", - "desc": "Readers see the earlier text until a moderator approves." - } - } - } -} +```bash tab="bun" +bun add @tanstack/react-query @tanstack/react-router --cwd plugins/newsroom ``` - +```bash tab="pnpm" +pnpm --filter newsroom add @tanstack/react-query @tanstack/react-router +``` -
    +```bash tab="npm" +npm install @tanstack/react-query @tanstack/react-router --workspace newsroom +``` + + - - +Create `src/pages/comment-redirect-page.tsx`: -```tsx title="plugins/newsroom/src/pages/comment-section.tsx" -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; -import { RichTextContent } from "@vitnode/core/components/rich-text"; -import { Button, buttonVariants } from "@vitnode/core/components/ui/button"; -import { Skeleton } from "@vitnode/core/components/ui/skeleton"; -import { type RichTextDocument } from "@vitnode/core/content"; -import { useSessionQuery } from "@vitnode/core/tanstack/auth"; +```tsx title="plugins/newsroom/src/pages/comment-redirect-page.tsx" +import { queryOptions } from "@tanstack/react-query"; +import { redirect } from "@tanstack/react-router"; +import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client"; +import { CONTENT_COMMENT_ANCHOR_PREFIX } from "@vitnode/core/tanstack/comments"; +import { contentPageItem } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; -import { HrefLink } from "@vitnode/core/tanstack/link"; -import { lazy, Suspense, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { useTranslations } from "use-intl"; +import { localizeHref } from "@vitnode/core/tanstack/i18n"; +import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; +import { z } from "zod"; import { manifest } from "@/manifest.gen"; -import type { CommentComposerProps, CommentOutcome } from "./comment-composer"; -import type { ArticleComment } from "./comment-data"; -import type { CommentEditorProps } from "./comment-editor"; - -import { commentsQueryKey, commentsQueryOptions } from "./comment-data"; -import { useCommentPermissions } from "./comment-permissions"; - -// The editor is heavy and most readers never write, so it loads on demand. -const CommentComposer = lazy(async () => await import("./comment-composer")); -const CommentEditor = lazy(async () => await import("./comment-editor")); - -const Composer = (props: CommentComposerProps) => ( - }> - - -); - -/** Who wrote a comment, and what they said. */ -const CommentBody = ({ comment }: { comment: ArticleComment }) => { - const t = useTranslations("newsroom.comments"); - - return ( -
    -

    - {comment.author?.name ?? t("someone")} -

    - -
    - ); -}; - -/** A comment, with Edit for the visitors who may use it. */ -const CommentItem = ({ - canEdit, - comment, - onSaved, -}: { - canEdit: boolean; - comment: ArticleComment; - onSaved: CommentEditorProps["onSaved"]; -}) => { - const t = useTranslations("newsroom.comments"); - const tWrite = useTranslations("core.global.content_write"); - const [editing, setEditing] = useState(null); - const [isOpening, setIsOpening] = useState(false); - - // Opens the editor on the comment's latest text, not on the cached copy. - const openEditor = async () => { - setIsOpening(true); - try { - const res = await fetcher({ +const zodCommentLink = z.object({ + article: z.object({ id: z.number() }), + id: z.number(), +}); + +/** Where a comment's permalink leads: its article's slug and the comment's id. */ +const commentLinkQueryOptions = (slug: string) => + queryOptions({ + meta: LOADER_DATA_QUERY_META, + queryFn: async () => { + const comment = await fetcher({ plugin: manifest.id, method: "get", - module: "content/comments", - path: "/{id}/edit", - args: { params: { id: comment.id } }, + module: "content/article-comments", + path: "/{slug}", + args: { params: { slug: encodeURIComponent(slug) } }, }); - if (res.status !== 200) { - const reason = - res.status === 401 - ? "unauthenticated" - : res.status === 403 - ? "forbidden" - : res.status === 404 - ? "not_found" - : "unknown"; - toast.error(tWrite("title"), { description: tWrite(reason) }); - - return; + if (comment.status === 404) return null; + if (comment.status !== 200) { + throw new Error(`Resolving the comment "${slug}" failed.`); } - setEditing((await res.json()).values.body); - } finally { - setIsOpening(false); - } - }; - - if (editing) { - return ( -
    -

    - {comment.author?.name ?? t("someone")} -

    - }> - { - setEditing(null); - }} - onSaved={async pending => { - setEditing(null); - await onSaved(pending); - }} - /> - -
    + const record = zodCommentLink.parse(await comment.json()); + const article = await fetcher({ + plugin: manifest.id, + method: "get", + module: "articles", + path: "/{id}/slug", + args: { params: { id: record.article.id } }, + }); + if (article.status === 404) return null; + if (article.status !== 200) { + throw new Error(`Resolving the article of the comment "${slug}" failed.`); + } + + return { article: (await article.json()).slug, comment: record.id }; + }, + queryKey: [manifest.id, "comment-link", slug], + }); + +export const route = defineRoute({ + load: async ({ context, params }) => { + const { article, comment } = contentPageItem( + await context.queryClient.query(commentLinkQueryOptions(params.slug)), ); - } - - return ( -
    - - {canEdit ? ( - - ) : null} -
    - ); -}; - -export const CommentSection = ({ - articleId, - articleSlug, -}: { - articleId: number; - articleSlug: string; -}) => { - const t = useTranslations("newsroom.comments"); - const queryClient = useQueryClient(); - const session = useSessionQuery(); - const [composerKey, setComposerKey] = useState(0); - const comments = useInfiniteQuery(commentsQueryOptions(articleId)); - - const pages = comments.data?.pages; - const items = useMemo( - () => pages?.flatMap(page => page.items) ?? [], - [pages], - ); - // One request for every comment on screen, batched by the hook. - const permissions = useCommentPermissions( - items.flatMap(item => (item.record ? [item.id] : [])), - ); - - const onPosted = async (outcome: CommentOutcome) => { - if (outcome !== "published") { - // Not in the public thread, so there is nothing to refresh. - toast.info(t(`${outcome}.title`), { description: t(`${outcome}.desc`) }); - - return; - } - toast.success(t("created.title"), { description: t("created.desc") }); - // Refetches every loaded page in order, with the new count. - await queryClient.invalidateQueries({ - queryKey: commentsQueryKey(articleId), + // oxlint-disable-next-line typescript/only-throw-error + throw redirect({ + href: `${localizeHref(`/news/${article}`, context.locale)}#${CONTENT_COMMENT_ANCHOR_PREFIX}${comment}`, + statusCode: 302, }); - }; + }, - const onEdited = async (pending: boolean) => { - if (pending) { - toast.info(t("edit_pending.title"), { - description: t("edit_pending.desc"), - }); - } else { - toast.success(t("edited.title"), { description: t("edited.desc") }); - } - await queryClient.invalidateQueries({ - queryKey: commentsQueryKey(articleId), - }); - }; - - return ( -
    -

    - {t("title", { count: pages?.[0]?.replyCount ?? 0 })} -

    - - {comments.isPending ? ( -
    - - {t("loading")} - - {[0, 1].map(key => ( -
    - - -
    - ))} -
    - ) : comments.isError && items.length === 0 ? ( -
    -

    - {t("load_failed.desc")} -

    - -
    - ) : items.length === 0 ? ( -

    {t("empty")}

    - ) : ( -
      - {items.map(item => - item.record ? ( -
    1. - -
    2. - ) : null, - )} -
    - )} - - {comments.hasNextPage ? ( - - ) : null} - - {session.data?.user ? ( - { - setComposerKey(key => key + 1); - await onPosted(outcome); - }} - parentId={null} - /> - ) : ( - - {t("sign_in")} - - )} -
    - ); -}; + head: () => ({ robots: "noindex, nofollow" }), +}); + +const CommentRedirectPage = () => null; + +export default CommentRedirectPage; +``` + +- **`contentPageItem`** turns a `null` into the not-found page, for a comment or article that is gone or not public. +- **`#comment-{id}`** is the element id the section gives every comment. The section scrolls to it, loading more pages until it appears, focuses it and rings it. + +Declare the page in `src/routes.ts`: + +```ts title="plugins/newsroom/src/routes.ts" + // [!code ++:4] + page("/news/comments/:slug", { + component: lazy(() => import("./pages/comment-redirect-page")), + }), +``` + +Build again, so the new route's types and the delivery URL exist: + + + +```bash tab="bun" +bun run build:plugins ``` -
    -
    +```bash tab="pnpm" +pnpm build:plugins +``` + +```bash tab="npm" +npm run build:plugins +``` + + + + + +
    ## Check the result -Open the article with comments from two members, `/news/welcome-to-the-newsroom` from [Comments](/docs/content-engine/threads#check-the-result): +Open an article with two comments by **Ada**, a member. Then check each action with the right account: + +1. **As Ada**, open **More actions** on one of the comments. It offers only **Edit**, because deleting and hiding your own comments are off by default. Click **Edit**, change the text and click **Save**. **Comment saved** appears and the comment shows the new text, marked **edited** when you changed it more than a minute after posting. + + + +2. **As Grace**, another member, the menu on Ada's comment offers **Report**. Pick **Spam** and click **Send report**. +3. **As the moderator**, the menu offers **Edit**, **Report**, **Hide** and **Delete**: + + + +4. Click **Hide**, type a reason and click **Hide**. The comment stays visible to you and to Ada, marked **Hidden** with the reason. Signed out, it is gone, and the count drops by one: + + + +5. Open **More actions** again and click **Unhide**. Everyone sees the comment again. +6. Click **Delete** on Ada's other comment. The section asks first, and the comment is gone after you confirm: + + + +7. In ModCP, open **Reports** and the report from step 2. Its title is the comment's first line. **Open current content** opens `/news/comments/{slug}`, which lands on the article with the reported comment scrolled into view, focused and outlined: + + -1. Sign in as one of them. **Edit** shows under your own comment only. -2. Click **Edit**, change the text and click **Save**. **Comment saved** appears and the new text shows in place. -3. Sign in as the other member, or open the page signed out. There is no **Edit** on comments you did not write. +With [Replies](/docs/content-engine/threads-frontend), a hidden or deleted comment that has answers stays in the thread as a placeholder, so its replies keep their place. - +To let members delete or hide their own comments, turn on **Allow deleting own content** or **Allow hiding own content** in their role (**AdminCP → Core → Users → Roles**). Ada's menu then offers **Delete** or **Hide** too. With a **Time to edit** of a few minutes, **Edit** disappears from Ada's comments once it runs out. -## Two saves of the same comment +## What the server checks -Newsroom comments have no `editorial`, so they have no `version`. `PATCH /comments/{id}` takes `{ "values": { "body": ... } }` and nothing else. When two tabs save the same comment, both answer `200` and the last save wins. Sending an `expectedVersion` anyway answers `400` with `Unrecognized key: "expectedVersion"`. +- **Every action.** The menu only hides buttons; each route checks the permission again and answers `401`, `403` or `404` when it says no. The section shows one message for it and keeps the comment as it was. +- **One action at a time.** While one action runs, the comment's menu waits, and **Post comment**, **Save** and the dialogs' buttons are disabled until the server answers. Nothing is shown as done before it accepts. +- **Edit window.** A save after the window closed answers `403` with the `edit_window_closed` issue, even from a page loaded before it closed, and the editor says **The time to edit this has run out** without losing the text. +- **Hidden comments.** Only the author and moderators get `hidden: true` and the reason; everyone else gets a placeholder or nothing. -For short comments that is usually fine. To stop silent overwrites, add `editorial: true` to the comment type and migrate. Every comment then gets a `version`, `GET /{id}/edit` returns it, and a stale save answers `409 CONTENT_VERSION_CONFLICT`. Pass `version` and `latestVersion` to `useContentPublicWrite`, send `expectedVersion` from `write.submit`, and render `write.conflict` above the editor, as the article's [Edit form](/docs/content-engine/public-write-edit#handle-version-conflicts) does. +## Related -Next, [Replies](/docs/content-engine/threads-frontend) lets comments answer each other. +- [Replies](/docs/content-engine/threads-frontend): let comments answer each other. +- [Comments API](/docs/content-engine/comments-api): every option and component, and building your own comment layout. +- [Reports](/docs/content-engine/reports): report reasons, snapshots and ModCP. diff --git a/apps/web/content/docs/content-engine/threads-frontend-tree.webp b/apps/web/content/docs/content-engine/threads-frontend-tree.webp index 95b1eb4e3f..008881bd11 100644 Binary files a/apps/web/content/docs/content-engine/threads-frontend-tree.webp and b/apps/web/content/docs/content-engine/threads-frontend-tree.webp differ diff --git a/apps/web/content/docs/content-engine/threads-frontend.mdx b/apps/web/content/docs/content-engine/threads-frontend.mdx index c6dd0a0b67..aa6f243e47 100644 --- a/apps/web/content/docs/content-engine/threads-frontend.mdx +++ b/apps/web/content/docs/content-engine/threads-frontend.mdx @@ -1,31 +1,33 @@ --- title: Replies -description: Let VitNode Content Engine comments answer each other. Raise thread.maxDepth, build the reply tree from the flat thread, add Reply with the parent id, keep removed comments as placeholders, and handle pagination, truncation and the depth limit. +description: Let VitNode Content Engine comments answer each other. Raise thread.maxDepth, pass the same limit to useContentComments, and the shared comment section adds Reply, nests answers and keeps removed comments as placeholders. icon: MessagesSquare implementation: - objective: Turn flat comments into threaded replies on an existing comment section. + objective: Turn the flat shared comment section into threaded replies. decisions: - How deep can replies nest? From 1 (flat) to 32 levels; recommend 4 to 6. constraints: - - 'Raise `thread.maxDepth` and build the tree from `parentId`; render `removed: true` items as placeholders that keep their replies.' - - Send `parent` with each reply; the server checks the parent and the depth. + - Raise `thread.maxDepth` on the comment type and pass the same number as `maxDepth` to `useContentComments`; export it once from the content type file. + - Keep `parentId` in the `create` callback; the server checks the parent and the depth. related: - title: Comments href: /docs/content-engine/threads - - title: Comment editing + - title: Comment actions href: /docs/content-engine/threads-editing + - title: Notifications + href: /docs/content-engine/notifications - title: thread reference href: /docs/content-engine/reference#thread --- -import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; import { ImgDocs } from "@/components/fumadocs/img"; import tree from "./threads-frontend-tree.webp"; -Replies let a comment answer another comment, so a conversation can branch. The thread route already returns each top-level comment followed by every answer below it; this page draws that as a tree, adds **Reply**, and keeps the shape when a comment in the middle is removed. +Replies let a comment answer another comment, so a conversation can branch. The thread route already returns each top-level comment followed by every answer below it, and the shared section draws them as a tree. This page raises the depth limit and tells the section about it, which turns on **Reply**. -This continues the flat comment section from [Comments](/docs/content-engine/threads), with **Edit** from [Comment editing](/docs/content-engine/threads-editing). If you skipped editing, render `CommentBody` where this page renders `CommentItem`, and leave `onEdited` and `permissions` out. +This continues the comment section from [Comments](/docs/content-engine/threads). Replies work with or without [Comment actions](/docs/content-engine/threads-editing). @@ -33,670 +35,91 @@ This continues the flat comment section from [Comments](/docs/content-engine/thr ### Raise the depth -Edit `src/content/comment.ts`. Export the limit, so the section can hide **Reply** where the server would refuse it. These are excerpts: +Edit `src/content/comment.ts`. Export the limit, so the section uses the same number as the server. These are excerpts: ```ts title="plugins/newsroom/src/content/comment.ts" /** How deep replies nest. The section hides **Reply** at this depth. */ // [!code ++:2] export const COMMENT_MAX_DEPTH = 4; - thread: { parent: "parent", root: "article", maxDepth: COMMENT_MAX_DEPTH }, // [!code highlight] +export const commentContentType = defineContentType({ + // ... + thread: { parent: "parent", root: "article", maxDepth: 1 }, // [!code --] + thread: { parent: "parent", root: "article", maxDepth: COMMENT_MAX_DEPTH }, // [!code ++] +}); ``` -`maxDepth` only changes what the server accepts, so there is nothing to migrate. Depth 1 is a top-level comment; with `4`, a reply to a reply to a reply is the deepest. +A top-level comment sits at depth 1, a reply to it at 2, and so on. With `4`, a comment at depth 4 can't be answered. The limit is a rule of the API, not a database setting, so rebuild the plugin and skip migrating: - - - + -### Build the tree - -Add a tree builder to the end of `src/pages/comment-data.ts`. This is an excerpt: - -```ts title="plugins/newsroom/src/pages/comment-data.ts" -export interface CommentNode extends CommentItem { - children: CommentNode[]; -} - -/** The flat, depth-first answer as a tree: a parent always comes before its replies. */ -export const buildCommentTree = ( - items: readonly CommentItem[], -): CommentNode[] => { - const nodes = new Map(); - const roots: CommentNode[] = []; - - for (const item of items) { - const node: CommentNode = { ...item, children: [] }; - nodes.set(item.id, node); - const parent = - item.parentId === null ? undefined : nodes.get(item.parentId); - if (parent) parent.children.push(node); - else roots.push(node); - } - - return roots; -}; +```bash tab="bun" +bun run build:plugins ``` -The thread route returns comments depth-first, so every reply comes after its parent. One pass with a `Map` is enough. If a reply's parent were ever missing from the page, the reply would fall back to the top level instead of disappearing. - - - - - -### Render a branch with Reply - -Edit `src/pages/comment-section.tsx`. Import the limit and the tree, then add `CommentBranch` above `CommentSection`. These are excerpts: - -```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" -import { COMMENT_MAX_DEPTH } from "@/content/comment"; // [!code ++] - -import type { ArticleComment, CommentNode } from "./comment-data"; // [!code highlight] - -import { - buildCommentTree, // [!code ++] - commentsQueryKey, - commentsQueryOptions, -} from "./comment-data"; +```bash tab="pnpm" +pnpm build:plugins ``` -```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" -/** What every comment in the tree needs from the section. */ -interface CommentThread { - articleId: number; - canReply: boolean; - onEdited: CommentEditorProps["onSaved"]; - onPosted: CommentComposerProps["onPosted"]; - permissions: ReturnType; -} - -/** A comment with its Reply form, followed by every answer below it. */ -const CommentBranch = ({ - node, - thread, -}: { - node: CommentNode; - thread: CommentThread; -}) => { - const t = useTranslations("newsroom.comments"); - const [replying, setReplying] = useState(false); - - return ( -
  • - {node.record ? ( - <> - - {thread.canReply && node.depth < COMMENT_MAX_DEPTH ? ( - - ) : null} - {replying ? ( -
    - { - setReplying(false); - await thread.onPosted(outcome); - }} - parentId={node.id} - /> - -
    - ) : null} - - ) : ( - // Hidden, but its public answers still need a parent to hang from. -

    {t("removed")}

    - )} - - {node.children.length > 0 ? ( -
      - {node.children.map(child => ( - - ))} -
    - ) : null} -
  • - ); -}; +```bash tab="npm" +npm run build:plugins ``` -- **Reply** opens a composer for that comment with `parentId={node.id}`, the same composer that posts top-level comments. `aria-expanded` tells screen readers whether the form is open, and **Cancel** closes it. -- **Reply is hidden at `COMMENT_MAX_DEPTH`**, where the server would answer `400`. It is also hidden for guests. -- **Removed comments.** A hidden comment that still has public answers comes back as `removed: true` with `record: null`. It renders as **This comment was removed.**, keeps its answers below it, and offers no **Reply** or **Edit**. -- **Answers** are a nested `
      ` with a left border, indented less on phones. + -### Render the tree in the section - -In `CommentSection`, build the tree, collect what each branch needs, render the branches instead of the flat list, and say when a page was cut short. These are excerpts: +### Tell the section -```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" - const tree = useMemo(() => buildCommentTree(items), [items]); // [!code ++] +Pass the limit to `useContentComments` in `src/pages/article-comments.tsx`. This is an excerpt: - // [!code ++:7] - const thread: CommentThread = { - articleId, - canReply: Boolean(session.data?.user), - onEdited, - onPosted, - permissions, - }; -``` +```tsx title="plugins/newsroom/src/pages/article-comments.tsx" +import { COMMENT_MAX_DEPTH } from "@/content/comment"; // [!code ++] -```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" -
        - {/* [!code ++:3] */} - {tree.map(node => ( - - ))} -
      - )} - - {/* [!code ++:3] */} - {pages?.some(page => page.truncated) ? ( -

      {t("truncated")}

      - ) : null} + const comments = useContentComments({ + ...articleCommentsThread(articleId), + maxDepth: COMMENT_MAX_DEPTH, // [!code ++] ``` -A posted reply goes through the same `onPosted` as a top-level comment. A published reply refreshes the thread, and a draft or pending one only shows its message. The top-level composer keeps its text, because only its own `onPosted` resets its key. - -
      - - - -### Add the labels - -Add these keys to the `comments` group in `src/locales/en.json`. `form.cancel` is the same key [Comment editing](/docs/content-engine/threads-editing) added: - -```json title="plugins/newsroom/src/locales/en.json" -{ - "newsroom": { - "comments": { - "form": { "reply_label": "Reply to {name}", "cancel": "Cancel" }, - "reply": "Reply", - "removed": "This comment was removed.", - "truncated": "This conversation is long, so some deeper replies are not shown." - } - } -} -``` +That is the whole change. `create` already sends `parent: parentId`, which is now the comment being answered instead of `null`. The section shows **Reply** under every comment above the limit, opens a composer under it, and draws replies indented below their parent. Below three levels it stops indenting, so deep branches stay readable on a phone. - - - -```tsx title="plugins/newsroom/src/pages/comment-section.tsx" -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; -import { RichTextContent } from "@vitnode/core/components/rich-text"; -import { Button, buttonVariants } from "@vitnode/core/components/ui/button"; -import { Skeleton } from "@vitnode/core/components/ui/skeleton"; -import { type RichTextDocument } from "@vitnode/core/content"; -import { useSessionQuery } from "@vitnode/core/tanstack/auth"; -import { fetcher } from "@vitnode/core/tanstack/fetcher"; -import { HrefLink } from "@vitnode/core/tanstack/link"; -import { lazy, Suspense, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { useTranslations } from "use-intl"; - -import { COMMENT_MAX_DEPTH } from "@/content/comment"; -import { manifest } from "@/manifest.gen"; - -import type { CommentComposerProps, CommentOutcome } from "./comment-composer"; -import type { ArticleComment, CommentNode } from "./comment-data"; -import type { CommentEditorProps } from "./comment-editor"; - -import { - buildCommentTree, - commentsQueryKey, - commentsQueryOptions, -} from "./comment-data"; -import { useCommentPermissions } from "./comment-permissions"; - -// The editor is heavy and most readers never write, so it loads on demand. -const CommentComposer = lazy(async () => await import("./comment-composer")); -const CommentEditor = lazy(async () => await import("./comment-editor")); - -const Composer = (props: CommentComposerProps) => ( - }> - - -); - -/** Who wrote a comment, and what they said. */ -const CommentBody = ({ comment }: { comment: ArticleComment }) => { - const t = useTranslations("newsroom.comments"); - - return ( -
      -

      - {comment.author?.name ?? t("someone")} -

      - -
      - ); -}; - -/** A comment, with Edit for the visitors who may use it. */ -const CommentItem = ({ - canEdit, - comment, - onSaved, -}: { - canEdit: boolean; - comment: ArticleComment; - onSaved: CommentEditorProps["onSaved"]; -}) => { - const t = useTranslations("newsroom.comments"); - const tWrite = useTranslations("core.global.content_write"); - const [editing, setEditing] = useState(null); - const [isOpening, setIsOpening] = useState(false); - - // Opens the editor on the comment's latest text, not on the cached copy. - const openEditor = async () => { - setIsOpening(true); - try { - const res = await fetcher({ - plugin: manifest.id, - method: "get", - module: "content/comments", - path: "/{id}/edit", - args: { params: { id: comment.id } }, - }); - if (res.status !== 200) { - const reason = - res.status === 401 - ? "unauthenticated" - : res.status === 403 - ? "forbidden" - : res.status === 404 - ? "not_found" - : "unknown"; - toast.error(tWrite("title"), { description: tWrite(reason) }); - - return; - } - - setEditing((await res.json()).values.body); - } finally { - setIsOpening(false); - } - }; - - if (editing) { - return ( -
      -

      - {comment.author?.name ?? t("someone")} -

      - }> - { - setEditing(null); - }} - onSaved={async pending => { - setEditing(null); - await onSaved(pending); - }} - /> - -
      - ); - } - - return ( -
      - - {canEdit ? ( - - ) : null} -
      - ); -}; - -/** What every comment in the tree needs from the section. */ -interface CommentThread { - articleId: number; - canReply: boolean; - onEdited: CommentEditorProps["onSaved"]; - onPosted: CommentComposerProps["onPosted"]; - permissions: ReturnType; -} - -/** A comment with its Reply form, followed by every answer below it. */ -const CommentBranch = ({ - node, - thread, -}: { - node: CommentNode; - thread: CommentThread; -}) => { - const t = useTranslations("newsroom.comments"); - const [replying, setReplying] = useState(false); - - return ( -
    1. - {node.record ? ( - <> - - {thread.canReply && node.depth < COMMENT_MAX_DEPTH ? ( - - ) : null} - {replying ? ( -
      - { - setReplying(false); - await thread.onPosted(outcome); - }} - parentId={node.id} - /> - -
      - ) : null} - - ) : ( - // Hidden, but its public answers still need a parent to hang from. -

      {t("removed")}

      - )} - - {node.children.length > 0 ? ( -
        - {node.children.map(child => ( - - ))} -
      - ) : null} -
    2. - ); -}; - -export const CommentSection = ({ - articleId, - articleSlug, -}: { - articleId: number; - articleSlug: string; -}) => { - const t = useTranslations("newsroom.comments"); - const queryClient = useQueryClient(); - const session = useSessionQuery(); - const [composerKey, setComposerKey] = useState(0); - const comments = useInfiniteQuery(commentsQueryOptions(articleId)); - - const pages = comments.data?.pages; - const items = useMemo( - () => pages?.flatMap(page => page.items) ?? [], - [pages], - ); - const tree = useMemo(() => buildCommentTree(items), [items]); - // One request for every comment on screen, batched by the hook. - const permissions = useCommentPermissions( - items.flatMap(item => (item.record ? [item.id] : [])), - ); - - const onPosted = async (outcome: CommentOutcome) => { - if (outcome !== "published") { - // Not in the public thread, so there is nothing to refresh. - toast.info(t(`${outcome}.title`), { description: t(`${outcome}.desc`) }); - - return; - } - - toast.success(t("created.title"), { description: t("created.desc") }); - // Refetches every loaded page in order, with the new count. - await queryClient.invalidateQueries({ - queryKey: commentsQueryKey(articleId), - }); - }; - - const onEdited = async (pending: boolean) => { - if (pending) { - toast.info(t("edit_pending.title"), { - description: t("edit_pending.desc"), - }); - } else { - toast.success(t("edited.title"), { description: t("edited.desc") }); - } - await queryClient.invalidateQueries({ - queryKey: commentsQueryKey(articleId), - }); - }; - - const thread: CommentThread = { - articleId, - canReply: Boolean(session.data?.user), - onEdited, - onPosted, - permissions, - }; - - return ( -
      -

      - {t("title", { count: pages?.[0]?.replyCount ?? 0 })} -

      - - {comments.isPending ? ( -
      - - {t("loading")} - - {[0, 1].map(key => ( -
      - - -
      - ))} -
      - ) : comments.isError && items.length === 0 ? ( -
      -

      - {t("load_failed.desc")} -

      - -
      - ) : items.length === 0 ? ( -

      {t("empty")}

      - ) : ( -
        - {tree.map(node => ( - - ))} -
      - )} - - {pages?.some(page => page.truncated) ? ( -

      {t("truncated")}

      - ) : null} - - {comments.hasNextPage ? ( - - ) : null} - - {session.data?.user ? ( - { - setComposerKey(key => key + 1); - await onPosted(outcome); - }} - parentId={null} - /> - ) : ( - - {t("sign_in")} - - )} -
      - ); -}; -``` - -
      -
      - ## Check the result -Open `/news/welcome-to-the-newsroom` as a member with a comment from another member below it: +Open an article with a comment and sign in as a member: -1. Click **Reply** under it, write an answer and click **Post comment**. The answer shows indented under the comment. -2. Reply to the answer, and to that answer. At depth 4, **Reply** is gone. -3. In **AdminCP → Newsroom → Comments**, hide a comment that has answers. Its place shows **This comment was removed.**, with its answers still below it, and it has no **Reply**. +1. Click **Reply** under the comment, write an answer and click **Post comment**. **Comment posted** appears and the answer shows indented below the comment. +2. Reply to the reply, and again, until a reply sits at depth 4. That reply has no **Reply** button. +3. Reload. The tree comes back the same way. -The thread route shows the same shape, with comment 3 hidden: +## Long conversations -```bash -curl "http://localhost:3000/api/newsroom/content/comments/thread?root=1" -``` +- **Pages are top-level comments.** The section asks for 20 top-level comments at a time, and each comes with every reply below it, so replies never split across pages. **Load more comments** adds the next ones. +- **Truncated pages.** One page holds at most 500 comments in total. When deep replies don't fit, the section says that some deeper comments aren't shown, instead of pretending the conversation ends there. +- **New top-level comments** land at the end, because comments are oldest first. While pages remain, **Load more comments** reaches them. +- **Permissions** with [Comment actions](/docs/content-engine/threads-editing) cover every loaded comment, in batches of 50, so a long thread keeps its menus. -```json title="200 (trimmed)" -{ - "items": [ - { "depth": 1, "id": 1, "parentId": null, "removed": false, "record": { "id": 1 } }, - { "depth": 2, "id": 10, "parentId": 1, "removed": false, "record": { "id": 10 } }, - { "depth": 1, "id": 2, "parentId": null, "removed": false, "record": { "id": 2 } }, - { "depth": 2, "id": 3, "parentId": 2, "removed": true, "record": null }, - { "depth": 3, "id": 4, "parentId": 3, "removed": false, "record": { "id": 4 } }, - { "depth": 4, "id": 5, "parentId": 4, "removed": false, "record": { "id": 5 } } - ], - "pageInfo": { "endCursor": "WyIyMDI2LTEwLTIzIDE5OjExOjIyLjAwMTIxOSIsMl0", "hasNextPage": false, "totalCount": 2 }, - "replyCount": 5, - "truncated": false -} -``` +## Removed and hidden comments in a tree -`totalCount` counts top-level comments; `replyCount` counts every public comment, so the heading reads **5 comments**. Comment 10 is the reply from step 1, listed right after its parent. A reply to comment 5, at depth 4, answers: - -```json title="400" -{ - "code": "CONTENT_INVALID_INPUT", - "contentTypeId": "newsroom.comment", - "issues": [ - { - "code": "unavailable", - "field": "parent", - "message": "Replies nest at most 4 levels deep. Answer a reply higher up." - } - ] -} -``` +- **Deleted with replies.** A comment deleted while it has replies stays as a **[deleted]** placeholder with no text and no author. It reads "Comment deleted by user" or "Comment removed by a moderator". Its replies keep their place, and you can still answer them. +- **Hidden with replies.** Readers who can't see a hidden comment get the same placeholder, so the branch below it stays readable. Its author and moderators see the real comment, marked **Hidden**. The section offers no **Reply** on a hidden comment. +- **Removed without replies.** The comment disappears. There's no branch below it to hold up. -## Pages and long conversations +The server keeps the tree valid on every write, in the AdminCP too: the parent must exist, be public and belong to the same article, a comment never answers itself or one of its own answers, and nothing nests deeper than `maxDepth`. A reply that breaks a rule answers `400`, and the composer keeps its text. -- **Pages are top-level comments.** `first` counts top-level comments, and each comes with every reply below it. **Load more comments** adds the next ones; replies never split across pages. -- **Truncated pages.** One page holds at most 500 comments in total. When deep replies do not fit, `truncated` is `true` and the section says so instead of pretending the conversation ends there. -- **New top-level comments** land at the end, because comments are oldest first. While pages remain, **Load more comments** reaches them. -- **Permissions** are still one hook call for every loaded comment. It splits more than 50 ids into batches, so a long thread keeps its **Edit** buttons. +## Related -The server keeps the tree valid on every write, in the AdminCP too: the parent must exist, be public and belong to the same article; a comment never answers itself or one of its own answers; and nothing nests deeper than `maxDepth`. The Forum plugin's `plugins/forum/src/pages/thread.tsx` builds the same thread with collapsible branches and permalinks. +- [Notifications](/docs/content-engine/notifications): optional. Tell members about replies to their comments, sent by the server with no change to the section. +- [Comments API](/docs/content-engine/comments-api): build your own tree layout with `ContentCommentsRoot`, as the Blog plugin does with its connectors and collapsible branches. +- [thread reference](/docs/content-engine/reference#thread): every `thread` option. diff --git a/apps/web/content/docs/content-engine/threads.mdx b/apps/web/content/docs/content-engine/threads.mdx index fb262823a7..cf6da1e540 100644 --- a/apps/web/content/docs/content-engine/threads.mdx +++ b/apps/web/content/docs/content-engine/threads.mdx @@ -1,55 +1,45 @@ --- title: Comments -description: Add comments under each VitNode Content Engine record with a comment content type and the thread option. Load them on the record's page, let signed-in members post, and tell published, draft and pending comments apart. +description: Add a comment section under each VitNode Content Engine record with a comment content type, the thread option and the shared ContentComments section from @vitnode/core. You write a small typed adapter, not a list and a form. icon: MessagesSquare implementation: - objective: Let signed-in members comment under the records of an existing content type, on its public page. + objective: Add a working comment section under the records of an existing content type, built from the shared core comments API. decisions: - - Which items get comments? Offer content types from the project's plugins that have a public API and a public page. - - 'Should comments go live at once? Recommend `create.status: "published"`; `"draft"` keeps them private until staff publish them.' + - Which plugin and content type get comments? Inspect the project's plugins for content types that have a public API and a public page, and offer those; do not ask the user to type identifiers. + - Which page renders the section? Offer the pages that already show one record of that type. + - Flat comments or replies? Flat by default; replies need a `maxDepth` above 1 on the type and in the hook. + - Which optional actions are needed now - edit, delete, hide and restore, report? Each one is a few lines in the same adapter. + - 'Should new comments go live at once? Recommend `create.status: "published"`; `"draft"` keeps them private, and a container policy can hold them for approval.' constraints: - - Define the comments as their own content type with the `thread` option, a required relation to the commented record as `root`, and a nullable self relation as `parent`. - - Read them with `GET /{publicApi.path}/thread` and post with `useContentPublicWrite`; check `approval.status` and `status` before saying a comment is posted. + - Define comments as their own content type with `thread`, a required relation to the commented record as `root`, a nullable self relation as `parent`, and a `publicApi.path` no other plugin on the site uses. + - 'Render them with `useContentComments` and `ContentComments` from `@vitnode/core/tanstack/comments`, with every `fetcher` call written inline in its callback; never copy a plugin''s comment section.' + - Never infer permissions from author ids; leave every action to the server's `can*` answers. related: - - title: Comment editing + - title: Comment actions href: /docs/content-engine/threads-editing - title: Replies href: /docs/content-engine/threads-frontend - - title: thread reference - href: /docs/content-engine/reference#thread + - title: Comments API + href: /docs/content-engine/comments-api --- -import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; import { Tab, Tabs } from "fumadocs-ui/components/tabs"; import { ImgDocs } from "@/components/fumadocs/img"; import section from "./threads-comments.webp"; -Comments are records of their own, in a comment content type that points at the article it answers. The `thread` option gives that type a route that returns an article's comments a page at a time, and the server checks where every comment may go. This page adds a comment list and a composer under each article. +Comments are records of their own, in a comment content type that points at the article it answers. The `thread` option gives that type a route that returns an article's comments a page at a time, and the server checks where every comment may go. On the page, `useContentComments` from `@vitnode/core/tanstack/comments` connects that route to your plugin with a few typed calls. `ContentComments` then renders the whole section: the list, the composer, loading, empty and error states, and a sign-in link for guests. + +This page adds flat comments under each article. [Comment actions](/docs/content-engine/threads-editing) adds editing, deleting, hiding and reports to the same section, and [Replies](/docs/content-engine/threads-frontend) nests answers. ## Before you begin You need: -- **The article type with its public API and public page**, from the beginner path up to [Content page](/docs/content-engine/public-pages). The article itself needs no `author` and no `publicWrite`. Staff can keep writing every article in the AdminCP. The comment type below has its own `author` field and its own `publicWrite`, which is what lets members comment. +- **The article type with its public API and public page**, from the beginner path up to [Content page](/docs/content-engine/public-pages). The article itself needs no `author` and no `publicWrite`. Staff can keep writing every article in the AdminCP. Members comment through the comment type's own `author` field and `publicWrite`, which you add below. - **Member accounts.** Signing in is part of every VitNode app; it does not depend on members writing articles. -- **`@tanstack/react-query` in the plugin.** Skip this if [Embedded list](/docs/content-engine/public-lists-embed#add-tanstack-query-to-the-plugin) or [Create form](/docs/content-engine/public-write-frontend#add-tanstack-query-to-the-plugin) already added it; otherwise run this from the workspace root: - - - -```bash tab="bun" -bun add @tanstack/react-query --cwd plugins/newsroom -``` - -```bash tab="pnpm" -pnpm --filter newsroom add @tanstack/react-query -``` - -```bash tab="npm" -npm install @tanstack/react-query --workspace newsroom -``` - +There's nothing else to install. The section, its forms and its messages ship with `@vitnode/core`. @@ -85,18 +75,25 @@ export const commentContentType = defineContentType({ }, publication: true, - visibility: true, publicApi: { - path: "comments", - fields: ["id", "slug", "body", "article", "parent", "publishedAt"], + path: "article-comments", + fields: [ + "id", + "slug", + "body", + "article", + "parent", + "publishedAt", + "createdAt", + "updatedAt", + ], people: ["author"], }, publicWrite: { ownership: { field: "author" }, create: { fields: ["body", "article", "parent"], status: "published" }, - update: { fields: ["body"] }, }, thread: { parent: "parent", root: "article", maxDepth: 1 }, @@ -108,8 +105,9 @@ export const commentContentType = defineContentType({ - **`article`** is the comment's **root**, the record it belongs to. It must be a required relation to another content type. `onDelete: "cascade"` removes the comments with their article. - **`parent`** is the comment being answered, or `null`. `thread` requires it even for flat comments; `maxDepth: 1` keeps every comment top level until [Replies](/docs/content-engine/threads-frontend). - **`slug`** with `generated: true` gets a random token, because a comment has no title. The public API needs exactly one slug. -- **`publicWrite`** lets members post `body` under an `article`, and `create.status: "published"` makes a new comment public at once. `update` lets authors fix their text later, in [Comment editing](/docs/content-engine/threads-editing). A comment's place is fixed when it is written, so `parent` and `article` are never in `update.fields`. -- **`visibility`** lets moderators hide a comment, and **`admin`** gives staff a list of comments in the AdminCP. +- **`publicApi.path`** is where the comment routes live: `/api/newsroom/content/article-comments/...`. Pick a path no other plugin on the site uses. A [permalink](/docs/content-engine/threads-editing#link-reports-to-the-comment) claims it site-wide later, and the Blog plugin already owns `comments`. +- **`createdAt` and `updatedAt`** in the public fields let the section show when a comment was written and mark it **edited**. +- **`publicWrite`** lets members post `body` under an `article`, and `create.status: "published"` makes a new comment public at once. A comment's place is fixed when it is written, so `parent` and `article` never become editable. @@ -166,7 +164,7 @@ import { commentContentType } from "@/content/comment"; // [!code ++] -### Label and migrate +### Label, build and migrate Add the comment type's labels to `src/locales/en.json`, next to `article` under `newsroom.content`, and its permission labels at the top level: @@ -198,28 +196,31 @@ Add the comment type's labels to `src/locales/en.json`, next to `article` under } ``` -Then migrate from the workspace root: +Then build and migrate from the workspace root. `db:migrate` reads your plugin's built files, so build first: - + ```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 ``` -The migration creates `newsroom_comments` with a foreign key to `newsroom_articles`. The thread route now answers: +The migration creates `newsroom_comments` with a foreign key to `newsroom_articles`. The build also regenerates the plugin's route types, which the next step relies on. The thread route now answers: ```bash -curl "http://localhost:3000/api/newsroom/content/comments/thread?root=1" +curl "http://localhost:3000/api/newsroom/content/article-comments/thread?root=1" ``` ```json title="200" @@ -230,397 +231,63 @@ curl "http://localhost:3000/api/newsroom/content/comments/thread?root=1" -### Read the comments +### Connect the comment routes -Create `src/pages/comment-data.ts`. This is the complete file. All loaded pages of one article's comments live in one infinite query, so refreshing it reloads every page in order: +Create `src/pages/article-comments.tsx`. This is the complete file: -```ts title="plugins/newsroom/src/pages/comment-data.ts" -import { infiniteQueryOptions } from "@tanstack/react-query"; +```tsx title="plugins/newsroom/src/pages/article-comments.tsx" import { - zodContentPublicPerson, - zodRichTextDocument, -} from "@vitnode/core/content"; + ContentComments, + contentCommentsQueryOptions, + useContentComments, +} from "@vitnode/core/tanstack/comments"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; -import { z } from "zod"; import { manifest } from "@/manifest.gen"; -/** Top-level comments per page. The thread route accepts 1 to 50. */ -const COMMENTS_PAGE_SIZE = 20; - -/** One comment as `publicApi.fields` and `people` return it. */ -const zodComment = z.object({ - author: zodContentPublicPerson.nullable().catch(null), - body: zodRichTextDocument, - id: z.number(), - publishedAt: z.string().nullable(), -}); - -const zodCommentPage = z.object({ - items: z.array( - z.object({ - depth: z.number(), - id: z.number(), - parentId: z.number().nullable(), - record: zodComment.nullable(), - removed: z.boolean(), +/** The thread under one article, shared by the page's loader and its section. */ +const articleCommentsThread = (articleId: number) => ({ + queryKey: [manifest.id, "comments", articleId], + thread: async ({ after, first }: { after: null | string; first: number }) => + await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/article-comments", + path: "/thread", + args: { + query: { root: String(articleId), first, ...(after ? { after } : {}) }, + }, }), - ), - pageInfo: z.object({ - endCursor: z.string().nullable(), - hasNextPage: z.boolean(), - totalCount: z.number(), - }), - replyCount: z.number(), - truncated: z.boolean(), }); -export type CommentPage = z.infer; - -export type CommentItem = CommentPage["items"][number]; - -export type ArticleComment = NonNullable; - -const fetchCommentPage = async (articleId: number, after?: string) => { - const res = await fetcher({ - plugin: manifest.id, - method: "get", - module: "content/comments", - path: "/thread", - args: { - query: { - root: String(articleId), - first: COMMENTS_PAGE_SIZE, - ...(after ? { after } : {}), - }, - }, - }); - if (res.status !== 200) throw new Error("Loading the comments failed."); - - // The thread route types `record` loosely, so parse it once here. - return zodCommentPage.parse(await res.json()); -}; - -export const commentsQueryKey = (articleId: number) => - [manifest.id, "comments", articleId] as const; - -/** Every loaded page under one key, so a refetch reloads them all in order. */ -export const commentsQueryOptions = (articleId: number) => - infiniteQueryOptions({ - getNextPageParam: (last: CommentPage) => - last.pageInfo.hasNextPage - ? (last.pageInfo.endCursor ?? undefined) - : undefined, - initialPageParam: undefined as string | undefined, - queryFn: async ({ pageParam }) => - await fetchCommentPage(articleId, pageParam), - queryKey: commentsQueryKey(articleId), - }); -``` - -- `GET /comments/thread?root={articleId}` returns up to `first` top-level comments, oldest first. `endCursor` asks for the next page. -- `items` is a flat list, and each comment in it has `depth`, `parentId` and `record`. With `maxDepth: 1`, every item is top level. -- The thread route types `record` loosely, so `zodCommentPage` checks the fields `publicApi` returns once, here. `author` comes from `people` as a small public profile, or `null` for a deleted account. -- `replyCount` counts every public comment, for the "2 comments" heading. - - - - - -### Write the composer - -Create `src/pages/comment-composer.tsx`. This is the complete file. It posts one comment and reports where it ended up: - -```tsx title="plugins/newsroom/src/pages/comment-composer.tsx" -import { - AutoForm, - type AutoFormOnSubmit, - AutoFormSubmitButton, -} from "@vitnode/core/components/form/auto-form"; -import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor"; -import { - emptyRichTextDocument, - isRichTextEmpty, - zodRichTextDocument, -} from "@vitnode/core/content"; -import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; -import { fetcher } from "@vitnode/core/tanstack/fetcher"; -import { useTranslations } from "use-intl"; -import { z } from "zod"; - -import { manifest } from "@/manifest.gen"; - -/** Where a new comment ended up. Only `published` is in the public thread. */ -export type CommentOutcome = "draft" | "pending" | "published"; - -export interface CommentComposerProps { - articleId: number; - label: string; - /** Called after the server stored the comment. */ - onPosted: (outcome: CommentOutcome) => Promise; - /** The comment being answered, or `null` for the article itself. */ - parentId: null | number; -} - -const createSchema = (required: string) => - z.object({ - body: zodRichTextDocument - .refine(doc => !isRichTextEmpty(doc), required) - .default(emptyRichTextDocument()), - }); - -const CommentComposer = ({ - articleId, - label, - onPosted, - parentId, -}: CommentComposerProps) => { - const t = useTranslations("newsroom.comments.form"); - const write = useContentPublicWrite({ fields: ["body"] }); - - const onSubmit: AutoFormOnSubmit> = async ( - { body }, - form, - ) => { - const record = await write.submit( - form, - async () => - await fetcher({ - plugin: manifest.id, - method: "post", - module: "content/comments", - path: "/", - args: { - body: { values: { article: articleId, body, parent: parentId } }, - }, - }), - ); - if (!record) return; - - // A pending comment is a draft too, so ask about approval first. - await onPosted( - record.approval?.status === "pending" - ? "pending" - : record.status === "published" - ? "published" - : "draft", - ); - }; - - return ( - ( - - ), +/** Warms the first page of comments in the article page's loader. */ +export const articleCommentsQueryOptions = (articleId: number) => + contentCommentsQueryOptions(articleCommentsThread(articleId)); + +export const ArticleComments = ({ articleId }: { articleId: number }) => { + const comments = useContentComments({ + ...articleCommentsThread(articleId), + create: async ({ body, parentId }) => + await fetcher({ + plugin: manifest.id, + method: "post", + module: "content/article-comments", + path: "/", + args: { + body: { values: { article: articleId, body, parent: parentId } }, }, - ]} - formSchema={createSchema(t("required"))} - layout={fields => <>{fields.body}} - onSubmit={onSubmit} - > - - {t("submit")} - - - ); -}; - -export default CommentComposer; -``` - -- **The request** posts `{ values: { article, body, parent } }`; `parent: null` answers the article itself. The server fills `author` from the session. -- **`write.submit`** returns the stored comment, or `null` after showing why the server said no, such as an expired session. -- **The outcome** reads both `approval` and `status`. A comment waiting for a moderator is also a draft, so `approval` is checked first. -- The file has a default export because the section loads it with `lazy`. The editor is heavy, and most readers never write. - - - - - -### Render the section - -Create `src/pages/comment-section.tsx`. This is the complete file: - -```tsx title="plugins/newsroom/src/pages/comment-section.tsx" -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; -import { RichTextContent } from "@vitnode/core/components/rich-text"; -import { Button, buttonVariants } from "@vitnode/core/components/ui/button"; -import { Skeleton } from "@vitnode/core/components/ui/skeleton"; -import { useSessionQuery } from "@vitnode/core/tanstack/auth"; -import { HrefLink } from "@vitnode/core/tanstack/link"; -import { lazy, Suspense, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { useTranslations } from "use-intl"; - -import type { CommentComposerProps, CommentOutcome } from "./comment-composer"; -import type { ArticleComment } from "./comment-data"; - -import { commentsQueryKey, commentsQueryOptions } from "./comment-data"; - -// The editor is heavy and most readers never write, so it loads on demand. -const CommentComposer = lazy(async () => await import("./comment-composer")); - -const Composer = (props: CommentComposerProps) => ( - }> - - -); - -/** Who wrote a comment, and what they said. */ -const CommentBody = ({ comment }: { comment: ArticleComment }) => { - const t = useTranslations("newsroom.comments"); - - return ( -
      -

      - {comment.author?.name ?? t("someone")} -

      - -
      - ); -}; - -export const CommentSection = ({ - articleId, - articleSlug, -}: { - articleId: number; - articleSlug: string; -}) => { - const t = useTranslations("newsroom.comments"); - const queryClient = useQueryClient(); - const session = useSessionQuery(); - const [composerKey, setComposerKey] = useState(0); - const comments = useInfiniteQuery(commentsQueryOptions(articleId)); - - const pages = comments.data?.pages; - const items = useMemo( - () => pages?.flatMap(page => page.items) ?? [], - [pages], - ); - - const onPosted = async (outcome: CommentOutcome) => { - if (outcome !== "published") { - // Not in the public thread, so there is nothing to refresh. - toast.info(t(`${outcome}.title`), { description: t(`${outcome}.desc`) }); - - return; - } + }), + }); - toast.success(t("created.title"), { description: t("created.desc") }); - // Refetches every loaded page in order, with the new count. - await queryClient.invalidateQueries({ - queryKey: commentsQueryKey(articleId), - }); - }; - - return ( -
      -

      - {t("title", { count: pages?.[0]?.replyCount ?? 0 })} -

      - - {comments.isPending ? ( -
      - - {t("loading")} - - {[0, 1].map(key => ( -
      - - -
      - ))} -
      - ) : comments.isError && items.length === 0 ? ( -
      -

      - {t("load_failed.desc")} -

      - -
      - ) : items.length === 0 ? ( -

      {t("empty")}

      - ) : ( -
        - {items.map(item => - item.record ? ( -
      1. - -
      2. - ) : null, - )} -
      - )} - - {comments.hasNextPage ? ( - - ) : null} - - {session.data?.user ? ( - { - setComposerKey(key => key + 1); - await onPosted(outcome); - }} - parentId={null} - /> - ) : ( - - {t("sign_in")} - - )} -
      - ); + return ; }; ``` -- **`useInfiniteQuery`** reads the pages the article's loader warms in the next step, so the comments are in the server-rendered HTML. When that warm-up failed, it loads them in the browser. -- **One state at a time.** `isPending` shows a skeleton with one screen reader label, an error with no comments shows **Try again**, an empty thread says so, and anything else is the list. A failed refetch keeps the comments already on screen. -- **`onPosted`** refreshes the thread only for a published comment. TanStack Query refetches every loaded page from the first, so the new comment and the heading's count update together. Drafts and pending comments only get their message; see [What a new comment can become](#what-a-new-comment-can-become). -- **Load more comments** calls `fetchNextPage` with the last cursor. It is disabled during any fetch, so a double click never loads a page twice. -- **Guests** see a sign-in link that brings them back to the article. +- **Each callback is one inline `fetcher` call** and returns its response unchanged. The module, path, arguments and answer stay typed from your routes, so a typo is a compile error. +- **`queryKey`** names this article's thread in the TanStack Query cache. Every write refreshes it, and the same key loads the first page in the loader below. +- **`thread`** gets the cursor and page size from the section and reads one page. **Load more comments** asks for the next one. +- **`create`** gets the text and `parentId`, which is always `null` for flat comments. Leave `create` out, and the section shows the conversation without a composer. +- **`ContentComments`** renders everything else. It loads its own messages, posts through the [write form helpers](/docs/content-engine/public-write-form-api), keeps the text when the server refuses it, and tells published, draft and pending comments apart.
      @@ -631,11 +298,9 @@ export const CommentSection = ({ Edit `src/pages/article-page.tsx`. The loader warms the first page of comments, which needs the query client from `defineRoute`. This is an excerpt: ```tsx title="plugins/newsroom/src/pages/article-page.tsx" -import type { PluginRoutePageProps } from "@vitnode/core/routing"; import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; -import { commentsQueryOptions } from "./comment-data"; // [!code ++] -import { CommentSection } from "./comment-section"; // [!code ++] +import { ArticleComments, articleCommentsQueryOptions } from "./article-comments"; // [!code ++] export const route = defineRoute({ load: async ({ context, params }) => { @@ -644,7 +309,7 @@ export const route = defineRoute({ // The first page of comments arrives with the article's HTML. A failure // leaves the section to show its own error instead of breaking the page. await context.queryClient - .infiniteQuery(commentsQueryOptions(page.item.id)) + .infiniteQuery(articleCommentsQueryOptions(page.item.id)) .catch(() => null); return page; @@ -668,88 +333,52 @@ Render the section at the end of `
      `, after the body: content={item.body} /> - {/* [!code ++] */} + {/* [!code ++] */}
      ``` -Then load the comment labels on the article's route in `src/routes.ts`: +Finally, load the section's messages with the page, so they arrive with its HTML instead of right after. In `src/routes.ts`: ```ts title="plugins/newsroom/src/routes.ts" page("/news/:slug", { component: lazy(() => import("./pages/article-page")), - messages: ["newsroom.articles", "newsroom.comments"], // [!code highlight] + messages: ["newsroom.articles", "core.comments"], // [!code highlight] }), ```
      - - -### Add the labels - -Add a `comments` group under `newsroom` in `src/locales/en.json`: - -```json title="plugins/newsroom/src/locales/en.json" -{ - "newsroom": { - "comments": { - "title": "{count, plural, =0 {Comments} one {# comment} other {# comments}}", - "empty": "No comments yet. Be the first.", - "loading": "Loading comments", - "retry": "Try again", - "load_more": "Load more comments", - "load_failed": { "title": "Could not load comments", "desc": "Check your connection and try again." }, - "sign_in": "Sign in to comment", - "form": { "label": "Your comment", "submit": "Post comment", "required": "Write a comment first." }, - "created": { "title": "Comment posted", "desc": "Your comment is below the article." }, - "draft": { - "title": "Comment saved as a draft", - "desc": "It is not public yet, so it does not appear below the article." - }, - "pending": { - "title": "Your comment is waiting for a moderator", - "desc": "A moderator reviews it before readers can see it." - }, - "someone": "Deleted user" - } - } -} -``` - -The error toasts of `useContentPublicWrite` come from core and need no labels. - - -
      ## Check the result Open `/news/welcome-to-the-newsroom`, the article staff created in [First content type](/docs/content-engine/create-a-content-type) and published in [Drafts & publishing](/docs/content-engine/publication): -1. Signed out, the section shows **No comments yet** and **Sign in to comment**. -2. Sign in as a member, write a comment and click **Post comment**. **Comment posted** appears, the comment shows with your name, the heading reads **1 comment**, and the editor is empty again. -3. Sign in as another member and comment too. The heading reads **2 comments**. +1. Signed out, the section shows **No comments yet** and **Sign in to comment**. The link brings you back to the article after signing in. +2. Sign in as a member, click **Write a comment…**, write something and click **Post comment**. **Comment posted** appears, the comment shows with your name, the heading reads **1 comment**, and the editor is empty again. +3. Reload the page. The comment is still there, which proves it came from the server and not from the form's memory. +4. Sign in as another member and comment too. The heading reads **2 comments**. -To try a draft, set `create.status` to `"draft"` in `src/content/comment.ts` and post again. **Comment saved as a draft** appears, and the list and the count stay as they were, because a draft is not public. Set it back to `"published"` afterwards. +To try a draft, set `create.status` to `"draft"` in `src/content/comment.ts`, rebuild and post again. **Comment saved as a draft** appears, and the list and the count stay as they were, because a draft is not public. Set it back to `"published"` afterwards. ## What a new comment can become -`POST /comments` answers `201` with the stored comment. Its `status` and `approval` say whether readers can see it: +`POST /article-comments` answers `201` with the stored comment. The section reads its `status` and `approval` and never calls a comment posted unless it is public: -| `status` | `approval` | When | What the section does | +| `status` | `approval` | When | What the section says | | --- | --- | --- | --- | | `"published"` | `null` | `create.status: "published"`, as here | **Comment posted**, and the thread refreshes | | `"draft"` | `null` | `create.status: "draft"` | **Comment saved as a draft**; staff publish it in the AdminCP | -| `"draft"` | `{ status: "pending" }` | The article is filed in a container that holds new comments for approval | **Your comment is waiting for a moderator** | +| `"draft"` | `{ status: "pending" }` | The article sits in a container that holds new comments for approval | **Your comment is waiting for a moderator** | -A newsroom comment can never be pending, because articles are not filed in a container. Pending comments need two more settings: mark the root relation `container: true`, and give its target a `field.container()` whose definition has `policies`. The Forum plugin's comments are set up this way: +A Newsroom comment is never pending, because articles are not filed in a container. Pending comments need two more settings: mark the root relation `container: true`, and give its target a `field.container()` whose definition has `policies`. The Forum plugin's comments are set up this way: ```ts title="plugins/forum/src/content/comment.ts (excerpt)" topic: field.relation({ @@ -764,8 +393,12 @@ A moderator then sets **New posts** to **Wait for approval** for comments in a f ## What the server checks -- **Signed out.** `POST /comments` answers `401`; the section never offers the composer to guests. -- **Placement.** The article must be public, the parent must belong to the same article, and nothing may nest deeper than `maxDepth`. A broken rule answers `400` with an issue on `parent`, which the composer shows as a toast. -- **Hidden articles.** A comment is only as public as its article. Once the article is unpublished or hidden, its comments drop out of the thread and their own routes answer `404`. +- **Signed out.** `POST /article-comments` answers `401`; the section offers guests a sign-in link instead of the composer. +- **Placement.** The article must be public (or hidden, for a reader allowed to see it), the parent must belong to the same article, and nothing may nest deeper than `maxDepth`. A broken rule answers `400`, and the section shows why without losing the text. +- **Hidden articles.** A comment is only as public as its article. Once the article is unpublished, its comments drop out of the thread and their own routes answer `404`. A *hidden* article keeps its conversation going for the people who can still see it, which means its authors, editors and moderators. Their response is never cached, and everybody else gets `404`. +- **Repeated clicks.** **Post comment** is disabled while the request runs, so one click sends one comment. + +## Related -Next, [Comment editing](/docs/content-engine/threads-editing) lets authors fix their comments. +- [Comment actions](/docs/content-engine/threads-editing): edit, delete, hide and report comments in the same section. +- [Comments API](/docs/content-engine/comments-api): every option of `useContentComments` and the shared components. diff --git a/apps/web/content/docs/dev/working-with-users/roles.mdx b/apps/web/content/docs/dev/working-with-users/roles.mdx index 2e978d87ab..cc123eb6aa 100644 --- a/apps/web/content/docs/dev/working-with-users/roles.mdx +++ b/apps/web/content/docs/dev/working-with-users/roles.mdx @@ -46,7 +46,20 @@ A fresh install gets four protected roles: - `default`: the role new members get. - `guest`: the role used for visitors who are not signed in. -Roles also carry allowances: file uploads on the **Content** tab, and personal information plus avatar and cover limits on the **Profile** tab. [Profile fields](/docs/dev/working-with-users/profile-fields#who-may-edit) and [Avatars and covers](/docs/dev/working-with-users/avatars#role-settings) explain how several roles combine. +Roles also carry allowances: file uploads and editing, deleting and hiding own content on the **Content** tab, and personal information plus avatar and cover limits on the **Profile** tab. [Profile fields](/docs/dev/working-with-users/profile-fields#who-may-edit) and [Avatars and covers](/docs/dev/working-with-users/avatars#role-settings) explain how several roles combine. + +### Editing own content + +**Allow editing own content** (on by default) lets members of the role edit the comments, topics and other Content Engine records they wrote from the site. Under it, **Time to edit** sets how many minutes after posting the edit stays open, or tick **Unlimited** to keep it open forever. Once the time runs out, **Edit** disappears from the member's comments and the API answers `edit_window_closed`. + +Two more switches, both off by default: + +- **Allow deleting own content** puts **Delete** on the member's own comments. A deleted record cannot be restored. A comment that has replies stays in the thread as a **[deleted]** placeholder reading "Comment deleted by user", so its replies keep their place. +- **Allow hiding own content** puts **Hide** on them. A hidden record disappears for everyone else; the member still sees it and can **Unhide** it. If a moderator hid it, it stays hidden. + +With several roles the most generous one wins: each action is allowed when any of the member's roles allows it, and the longest time to edit applies, with **Unlimited** beating any number. A forum board's own [edit window](/docs/content-engine/container-policies-api) still applies on top, so both have to allow the edit. + +Moderators don't need any of this. Every content type members write gets moderator permissions in **AdminCP → Staff → Moderators**, such as **Edit any comment**, **Delete any comment** and **Hide and unhide comments**, which work on everyone's content at any age. ## Primary and secondary roles diff --git a/apps/web/content/docs/guides/blog.mdx b/apps/web/content/docs/guides/blog.mdx index 4eb0bb4cbc..203a087560 100644 --- a/apps/web/content/docs/guides/blog.mdx +++ b/apps/web/content/docs/guides/blog.mdx @@ -181,6 +181,41 @@ you scroll, highlighting every section you are looking at. On phones it folds into a box above the cover. Headings get their own link anchors, so you can share `/blog/article/building-a-plugin#why-plugins`. +### Comments + +Below the article sits the conversation, styled like a chat: every comment is a +bubble on a timeline, with your own comments tinted, and replies nest up to +six levels deep. Signed-in members write with the rich text editor. Guests can +read everything and get a **Sign in to comment** button that brings them back to +the article afterwards. + +Under each bubble sit three actions: + +- **Reply** opens the editor right under the comment. +- **Share** copies a link to the comment (`#comment-{slug}`); opening it loads + more comments until the linked one is on screen, and highlights it. +- **⋯** holds **Edit** for your own comments and **Report**, which sends the + comment to the ModCP. That includes your own comments, in case you want a + moderator to look at something you wrote. Moderators also get **Hide** and + **Unhide**. A hidden comment disappears for readers, while moderators and its + author still see it as a dashed bubble marked **Hidden**. + +A comment with replies gets a round button on the timeline that folds them +away (**Hide 2 replies**) and back (**Show 2 replies**). Replies stop moving +right after four levels on a computer and two on a phone; deeper ones line up +with their parent and say who they answer (**Replying to Ada**), so a long +thread stays readable on any screen. + +Comments are a Content Engine [thread](/docs/content-engine/threads): the +`blog.comment` content type, stored in `blog_comments`, with the article as the +thread's root. One conversation is shared by every translation of an article, +and comments load in the browser, so the article's cached page never shows an +old conversation. Hiding or unpublishing an article takes its comments with it; +deleting it deletes them. Comments have no AdminCP screen. Moderate them from +the article itself and the ModCP. + +{/* Image prompt: VitNode blog article page, the comments section below the article text: heading "3 comments", chat-style bubbles on a thin vertical timeline line, each bubble with the author name and "· 2 hours ago" in its first line, and a small "Reply · Share · ⋯" row under it; one smaller nested reply bubble; a "Leave a comment" card at the end. Light theme, 1280x900. */} + ### Categories and article counts in your own pages The sidebar and the category tags come from a small public route that returns @@ -356,6 +391,10 @@ so rebuild the `blog.post` collection in **AdminCP → Debug Panel → Search** afterwards. If you map the article route in `routePaths`, rename the key to `'/blog/article/:slug'`. +Article comments add the `blog_comments` table, so the migration below creates +it. There's nothing else to set up. The comments appear under every published +article. + Run your migrations after you upgrade `@vitnode/blog`: diff --git a/apps/web/content/docs/ui/meta.json b/apps/web/content/docs/ui/meta.json index f7f8618640..f129ba593b 100644 --- a/apps/web/content/docs/ui/meta.json +++ b/apps/web/content/docs/ui/meta.json @@ -43,6 +43,7 @@ "button", "button-group", "copy-button", + "share-popover", "toggle", "toggle-group", "---Overlays---", diff --git a/apps/web/content/docs/ui/share-popover.mdx b/apps/web/content/docs/ui/share-popover.mdx new file mode 100644 index 0000000000..360ce8fad4 --- /dev/null +++ b/apps/web/content/docs/ui/share-popover.mdx @@ -0,0 +1,96 @@ +--- +title: Share Popover +description: A share menu with the link, a copy button, one-click sharing to social networks and the device's own share sheet. +icon: Share2 +--- + +## Preview + + + +## Usage + +```ts +import { SharePopover } from '@vitnode/core/components/share-popover' +``` + +```tsx + + + +``` + +The child is the trigger. Any button that forwards its ref and props works. + +## What's inside + +- **The link** in a read-only field that selects itself on focus, with a [Copy Button](/docs/ui/copy-button) at the end. +- **One-click sharing** to X, Bluesky, Facebook, Reddit and LinkedIn, plus email. Each opens in a new tab with the link (and `title`, where the network takes text) filled in, and the popover closes behind it. +- **More options…** opens the device's own share sheet, with Messages, WhatsApp, AirDrop and friends. It only shows up in browsers that have one (most phones, Safari, Edge), so nobody gets a button that does nothing. + +## Relative links + +`url` can be root-relative, or just a hash. It's resolved against the current page when the popover opens, so it renders fine on the server, where there's no `window` to ask. + +```tsx + + + +``` + +Blog comments use exactly this, so a shared comment lands on its spot in the thread. + +## Accessibility + +- Each network button is labelled "Share on X" (and so on), with a tooltip that glides between them. +- Network links open in a new tab with `rel="noopener noreferrer"`. +- Canceling the native share sheet stays quiet. A real failure shows an error toast. + +## Props + +import { TypeTable } from 'fumadocs-ui/components/type-table' + + + +## Related + +- [Copy Button](/docs/ui/copy-button) +- [Popover](/docs/ui/popover) diff --git a/apps/web/src/docs/examples/share-popover.tsx b/apps/web/src/docs/examples/share-popover.tsx new file mode 100644 index 0000000000..1cb45aed86 --- /dev/null +++ b/apps/web/src/docs/examples/share-popover.tsx @@ -0,0 +1,21 @@ +import { SharePopover } from '@vitnode/core/components/share-popover' +import { Button } from '@vitnode/core/components/ui/button' +import { Card } from '@vitnode/core/components/ui/card' +import { Share2Icon } from 'lucide-react' + +export default function SharePopoverExample() { + return ( + + + + + + ) +} diff --git a/apps/web/src/locales/@vitnode/blog/pl.json b/apps/web/src/locales/@vitnode/blog/pl.json index 5aa8100471..229b68d2dd 100644 --- a/apps/web/src/locales/@vitnode/blog/pl.json +++ b/apps/web/src/locales/@vitnode/blog/pl.json @@ -149,6 +149,21 @@ }, "label": "{count, plural, one {Artykuł} few {Artykuły} many {Artykułów} other {Artykułu}}", "title": "Artykuły" + }, + "comment": { + "label": "{count, plural, one {Komentarz} few {Komentarze} many {Komentarzy} other {Komentarza}}", + "title": "Komentarze", + "desc": "Komentarze czytelników pod artykułami i odpowiedzi na nie.", + "fields": { + "slug": "Link bezpośredni", + "body": "Treść", + "author": "Autor", + "article": "Artykuł", + "parent": "W odpowiedzi na", + "publishedAt": "Opublikowano", + "updatedAt": "Zaktualizowano", + "createdAt": "Utworzono" + } } }, "title": "Blog", @@ -200,6 +215,15 @@ "desc": "Coś poszło nie tak po naszej stronie. Spróbuj ponownie za chwilę.", "retry": "Spróbuj ponownie" } + }, + "comments": { + "article_author": "Autor", + "share": { + "label": "Udostępnij", + "description": "Komentarz od {name}" + }, + "replying_to": "W odpowiedzi: {name}", + "removed_comment": "usunięty komentarz" } }, "@vitnode/blog:categories": "Kategorie", @@ -225,5 +249,16 @@ "description": "Tłumaczy jedno pole artykułu na inny język." } } - } + }, + "@vitnode/blog:comment": "Komentarze", + "@vitnode/blog:comment:can_view": "Przeglądanie komentarzy", + "@vitnode/blog:comment:can_create": "Tworzenie komentarzy", + "@vitnode/blog:comment:can_edit": "Edycja dowolnego komentarza", + "@vitnode/blog:comment:can_delete": "Usuwanie dowolnego komentarza", + "@vitnode/blog:comment:can_publish": "Publikowanie i wycofywanie komentarzy", + "@vitnode/blog:comment:can_hide": "Ukrywanie i pokazywanie komentarzy", + "@vitnode/blog:comment:can_restore": "Przywracanie wcześniejszej wersji komentarza", + "@vitnode/blog:comment:can_view_reports": "Przeglądanie zgłoszeń komentarzy", + "@vitnode/blog:comment:can_manage_reports": "Przejmowanie, przydzielanie i zamykanie zgłoszeń komentarzy", + "@vitnode/blog:comment:can_view_hidden": "Otwieranie ukrytych komentarzy" } diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 3b41f23ebe..6d8a6a4c92 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -604,6 +604,20 @@ "total_max_storage": "Całkowity maks. rozmiar pamięci", "unlimited": "Bez limitu" }, + "edit_own_content": { + "allow": "Zezwól na edycję własnych treści", + "minutes": "Czas na edycję", + "minutes_desc": "Jak długo po opublikowaniu członek może jeszcze edytować treść.", + "in_unit": "w minutach", + "or": "lub", + "unlimited": "Bez limitu" + }, + "delete_own_content": { + "allow": "Zezwól na usuwanie własnych treści" + }, + "hide_own_content": { + "allow": "Zezwól na ukrywanie własnych treści" + }, "ai": { "unlimited": "Punkty bez limitu", "unlimited_desc": "Użytkowników z tą rolą nie zatrzyma miesięczny limit. Budżet strony nadal obowiązuje.", @@ -2494,6 +2508,15 @@ "copied": "Skopiowano", "copy": "Kopiuj", "copy_failed": "Nie udało się skopiować do schowka. Spróbuj zaznaczyć tekst.", + "share": { + "title": "Udostępnij", + "link": "Link do udostępnienia", + "copy": "Kopiuj link", + "on": "Udostępnij na {network}", + "email": "Wyślij e-mailem", + "more": "Więcej opcji…", + "failed": "Nie udało się otworzyć menu udostępniania urządzenia." + }, "decrease": "Zmniejsz", "increase": "Zwiększ", "more_count": "+{count} więcej", @@ -3161,6 +3184,7 @@ }, "types": { "blog_post": "Wpis", + "comment": "Komentarz", "forum_comment": "Komentarz", "forum_topic": "Temat", "unknown": "Treść" @@ -3246,6 +3270,113 @@ "test_title": "E-maile z powiadomieniami działają", "test_body": "Jeśli to czytasz, e-maile z powiadomieniami z tej strony docierają do Twojej skrzynki. Nic nie musisz robić." } + }, + "comments": { + "title": "{count, plural, =0 {Komentarze} one {# komentarz} few {# komentarze} many {# komentarzy} other {# komentarza}}", + "loading": "Wczytywanie komentarzy", + "empty": { + "title": "Brak komentarzy", + "desc": "Masz jakieś przemyślenia? Rozpocznij rozmowę." + }, + "load_failed": { + "title": "Nie udało się wczytać komentarzy", + "desc": "Sprawdź połączenie i spróbuj ponownie.", + "retry": "Spróbuj ponownie" + }, + "load_more": "Wczytaj więcej komentarzy", + "truncated": "Ten wątek jest bardzo długi, więc część głębszych komentarzy nie jest tu widoczna.", + "anchor_missing": "Komentarz, do którego prowadzi ten link, nie jest już dostępny.", + "compose": { + "title": "Zostaw komentarz", + "guest": "Aby komentować, potrzebujesz konta.", + "sign_in": "Zaloguj się, aby skomentować", + "open": "Napisz komentarz…" + }, + "form": { + "body_label": "Twój komentarz", + "edit_label": "Edytuj komentarz", + "answer_label": "Odpowiedz: {name}", + "submit": "Opublikuj komentarz", + "save": "Zapisz", + "cancel": "Anuluj", + "required": "Najpierw napisz komentarz.", + "proposed": "Edytujesz swoją zmianę, która wciąż czeka na moderatora." + }, + "outcome": { + "published": { + "title": "Komentarz opublikowany", + "desc": "Twój komentarz jest już widoczny w rozmowie." + }, + "draft": { + "title": "Komentarz zapisany jako szkic", + "desc": "Nie jest jeszcze publiczny, więc nikt inny go nie widzi." + }, + "pending": { + "title": "Twój komentarz czeka na moderatora", + "desc": "Pojawi się w rozmowie po zatwierdzeniu." + }, + "edited": { + "title": "Komentarz zapisany", + "desc": "Zmiany są już widoczne." + }, + "edit_pending": { + "title": "Twoja zmiana czeka na moderatora", + "desc": "Komentarz zachowuje obecną treść, dopóki zmiana nie zostanie zatwierdzona." + } + }, + "errors": { + "title": "Nie udało się", + "unknown": "Coś poszło nie tak. Spróbuj ponownie za chwilę.", + "forbidden": "Nie możesz tego tutaj zrobić.", + "not_found": "To już nie istnieje albo nie masz do tego dostępu.", + "unauthenticated": "Twoja sesja wygasła. Zaloguj się i spróbuj ponownie.", + "busy": "Inne działanie w tej rozmowie wciąż trwa. Poczekaj chwilę i spróbuj ponownie." + }, + "permalink": "Link do tego komentarza", + "edited_label": "edytowano", + "reply": "Odpowiedz", + "edit": "Edytuj", + "report": "Zgłoś", + "report_type": "komentarz", + "more_actions": "Więcej działań dla komentarza od {name}", + "someone": "ten komentarz", + "deleted_user": "Usunięty użytkownik", + "removed": "Ten komentarz został usunięty.", + "show_replies": "{count, plural, one {Pokaż # odpowiedź} few {Pokaż # odpowiedzi} many {Pokaż # odpowiedzi} other {Pokaż # odpowiedzi}}", + "hide_replies": "{count, plural, one {Ukryj # odpowiedź} few {Ukryj # odpowiedzi} many {Ukryj # odpowiedzi} other {Ukryj # odpowiedzi}}", + "visibility": { + "hidden_label": "Ukryty", + "hidden_reason": "Powód: {reason}", + "hide": { + "action": "Ukryj", + "title": "Komentarz ukryty", + "desc": "Widzą go teraz tylko moderatorzy i jego autor.", + "prompt_title": "Ukryć ten komentarz?", + "prompt_desc": "Przestaną go widzieć wszyscy poza moderatorami i jego autorem.", + "reason": "Powód", + "reason_desc": "Opcjonalnie. Zobaczą go moderatorzy i autor." + }, + "unhide": { + "action": "Pokaż", + "title": "Komentarz znów widoczny", + "desc": "Każdy może go przeczytać w rozmowie." + } + }, + "delete": { + "action": "Usuń", + "title": "Usunąć ten komentarz?", + "desc": "Zniknie dla wszystkich i nie da się go przywrócić. Odpowiedzi na niego zostaną w rozmowie.", + "confirm": "Usuń komentarz", + "done": { + "title": "Komentarz usunięty", + "desc": "Zniknął z rozmowy." + } + }, + "placeholder": { + "name": "[usunięty]", + "by_author": "Komentarz usunięty przez użytkownika", + "by_moderator": "Komentarz usunięty przez moderatora" + } } }, "@vitnode/core:files:can_edit_alt": "Edycja tekstu alternatywnego obrazów", diff --git a/apps/web/src/page-cache.gen.ts b/apps/web/src/page-cache.gen.ts index 3103dbe0bc..db33dcf2f3 100644 --- a/apps/web/src/page-cache.gen.ts +++ b/apps/web/src/page-cache.gen.ts @@ -50,11 +50,13 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/forum/topics/:id/edit' }, { path: '/blog/article/:slug', maxAge: 300, staleWhileRevalidate: 60 }, { path: '/blog/category/:slug', maxAge: 300, staleWhileRevalidate: 60 }, + { path: '/blog/comments/:slug' }, { path: '/forum/comments/:slug' }, { path: '/forum/topics/:slug', maxAge: 60, staleWhileRevalidate: 30 }, { path: '/login/sso/:providerId' }, { path: '/modcp/approvals/:id' }, { path: '/modcp/reports/:id' }, + { path: '/news/comments/:slug' }, { path: '/newsroom/guide/:topic' }, { path: '/newsroom/notes/:slug' }, { path: '/newsroom/pages/:slug' }, diff --git a/packages/vitnode/src/api/lib/own-content-policy.ts b/packages/vitnode/src/api/lib/own-content-policy.ts new file mode 100644 index 0000000000..d998dffff6 --- /dev/null +++ b/packages/vitnode/src/api/lib/own-content-policy.ts @@ -0,0 +1,49 @@ +import type { Context } from "hono"; + +import { inArray } from "drizzle-orm"; + +import type { OwnContentPolicy } from "@/lib/own-content"; + +import { core_roles } from "@/database/roles"; +import { effectiveOwnContentPolicy } from "@/lib/own-content"; + +import { getUserRoleIds } from "./check-staff-permission"; + +const resolved = new WeakMap>(); + +const readPolicy = async ( + c: Context, + user: { id: number; roleId: number }, +): Promise => { + const roleIds = await getUserRoleIds(c, user); + const roles = await c + .get("db") + .select({ + allowDeleteOwnContent: core_roles.allowDeleteOwnContent, + allowEditOwnContent: core_roles.allowEditOwnContent, + allowHideOwnContent: core_roles.allowHideOwnContent, + editOwnContentMinutes: core_roles.editOwnContentMinutes, + }) + .from(core_roles) + .where(inArray(core_roles.id, roleIds)); + + return effectiveOwnContentPolicy(roles); +}; + +/** + * What the member may do with content they wrote - edit (and for how long), + * delete, hide - from their primary and secondary roles' settings. Read once + * per request. Server-only. + */ +export const resolveOwnContentPolicy = async ( + c: Context, + user: { id: number; roleId: number }, +): Promise => { + const cached = resolved.get(c); + if (cached) return await cached; + + const policy = readPolicy(c, user); + resolved.set(c, policy); + + return await policy; +}; diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index 71732b791e..91f677fc3a 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -277,9 +277,11 @@ export function buildApiPlugin< validateSearchIndexers(indexers.map(indexer => ({ ...indexer, pluginId }))); // A custom rule may reuse a type the plugin also lists itself. + const listedNotificationTypes = new Set(pluginNotificationTypes); for (const config of contentNotifications ?? []) { for (const type of config.notificationTypes) { - if (!pluginNotificationTypes.includes(type)) { + if (!listedNotificationTypes.has(type)) { + listedNotificationTypes.add(type); pluginNotificationTypes.push(type); } } diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts index 06154e065a..780c46594a 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts @@ -8,6 +8,7 @@ import { parseEmojiIcon, serializeEmojiIcon, } from "@/lib/emoji-icon"; +import { ROLE_EDIT_OWN_CONTENT_MAX_MINUTES } from "@/lib/own-content"; // Role names are not a column on `core_roles` - every translation lives in // `core_languages_words`, so the name is the full list of per-language values. @@ -32,6 +33,13 @@ export const zodRoleImageSizeSchema = z export const zodRolePrefixSchema = z.string().max(EMOJI_ICON_MAX_LENGTH); +export const zodRoleEditOwnContentMinutesSchema = z + .number() + .int() + .min(1) + .max(ROLE_EDIT_OWN_CONTENT_MAX_MINUTES) + .nullable(); + export const zodCreateRoleAdminSchema = z.object({ name: zodRoleNameSchema, color: z.string().max(50).optional(), @@ -41,6 +49,10 @@ export const zodCreateRoleAdminSchema = z.object({ maxStorageForSubmit: zodRoleStorageSchema.optional(), allowUploadAvatar: z.boolean().optional(), allowEditPersonalInfo: z.boolean().optional(), + allowEditOwnContent: z.boolean().optional(), + editOwnContentMinutes: zodRoleEditOwnContentMinutesSchema.optional(), + allowDeleteOwnContent: z.boolean().optional(), + allowHideOwnContent: z.boolean().optional(), maxAvatarSize: zodRoleImageSizeSchema.optional(), allowUploadCover: z.boolean().optional(), maxCoverSize: zodRoleImageSizeSchema.optional(), @@ -86,6 +98,10 @@ export const createRoleAdminRoute = buildRoute({ maxStorageForSubmit, allowUploadAvatar, allowEditPersonalInfo, + allowEditOwnContent, + editOwnContentMinutes, + allowDeleteOwnContent, + allowHideOwnContent, maxAvatarSize, allowUploadCover, maxCoverSize, @@ -104,6 +120,10 @@ export const createRoleAdminRoute = buildRoute({ ...(allowEditPersonalInfo === undefined ? {} : { allowEditPersonalInfo }), + ...(allowEditOwnContent === undefined ? {} : { allowEditOwnContent }), + editOwnContentMinutes: editOwnContentMinutes ?? null, + allowDeleteOwnContent: allowDeleteOwnContent ?? false, + allowHideOwnContent: allowHideOwnContent ?? false, ...(maxAvatarSize === undefined ? {} : { maxAvatarSize }), ...(allowUploadCover === undefined ? {} : { allowUploadCover }), ...(maxCoverSize === undefined ? {} : { maxCoverSize }), diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts index 4f7ca69e57..2ed475c95a 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts @@ -40,6 +40,10 @@ const rolesAdminListSchema = z.object({ maxStorageForSubmit: z.number().nullable(), allowUploadAvatar: z.boolean(), allowEditPersonalInfo: z.boolean(), + allowEditOwnContent: z.boolean(), + editOwnContentMinutes: z.number().nullable(), + allowDeleteOwnContent: z.boolean(), + allowHideOwnContent: z.boolean(), maxAvatarSize: z.number(), allowUploadCover: z.boolean(), maxCoverSize: z.number(), @@ -128,6 +132,10 @@ export const listRolesAdminRoute = buildRoute({ maxStorageForSubmit: core_roles.maxStorageForSubmit, allowUploadAvatar: core_roles.allowUploadAvatar, allowEditPersonalInfo: core_roles.allowEditPersonalInfo, + allowEditOwnContent: core_roles.allowEditOwnContent, + editOwnContentMinutes: core_roles.editOwnContentMinutes, + allowDeleteOwnContent: core_roles.allowDeleteOwnContent, + allowHideOwnContent: core_roles.allowHideOwnContent, maxAvatarSize: core_roles.maxAvatarSize, allowUploadCover: core_roles.allowUploadCover, maxCoverSize: core_roles.maxCoverSize, diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts index 23e2b0a334..8dd0e22578 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts @@ -26,6 +26,10 @@ const roleAdminSchema = z.object({ maxStorageForSubmit: z.number().nullable(), allowUploadAvatar: z.boolean(), allowEditPersonalInfo: z.boolean(), + allowEditOwnContent: z.boolean(), + editOwnContentMinutes: z.number().nullable(), + allowDeleteOwnContent: z.boolean(), + allowHideOwnContent: z.boolean(), maxAvatarSize: z.number(), allowUploadCover: z.boolean(), maxCoverSize: z.number(), @@ -90,6 +94,10 @@ export const showRoleAdminRoute = buildRoute({ maxStorageForSubmit: core_roles.maxStorageForSubmit, allowUploadAvatar: core_roles.allowUploadAvatar, allowEditPersonalInfo: core_roles.allowEditPersonalInfo, + allowEditOwnContent: core_roles.allowEditOwnContent, + editOwnContentMinutes: core_roles.editOwnContentMinutes, + allowDeleteOwnContent: core_roles.allowDeleteOwnContent, + allowHideOwnContent: core_roles.allowHideOwnContent, maxAvatarSize: core_roles.maxAvatarSize, allowUploadCover: core_roles.allowUploadCover, maxCoverSize: core_roles.maxCoverSize, diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts index b36aa6c10f..af5660bf27 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts @@ -9,6 +9,7 @@ import { parseEmojiIcon, serializeEmojiIcon } from "@/lib/emoji-icon"; import { assertCanManageAdminRole } from "../lib/assert-manage-admin-role"; import { + zodRoleEditOwnContentMinutesSchema, zodRoleImageSizeSchema, zodRoleNameSchema, zodRolePrefixSchema, @@ -25,6 +26,10 @@ export const zodUpdateRoleAdminSchema = z maxStorageForSubmit: zodRoleStorageSchema, allowUploadAvatar: z.boolean(), allowEditPersonalInfo: z.boolean(), + allowEditOwnContent: z.boolean(), + editOwnContentMinutes: zodRoleEditOwnContentMinutesSchema, + allowDeleteOwnContent: z.boolean(), + allowHideOwnContent: z.boolean(), maxAvatarSize: zodRoleImageSizeSchema, allowUploadCover: z.boolean(), maxCoverSize: zodRoleImageSizeSchema, @@ -120,6 +125,18 @@ export const updateRoleAdminRoute = buildRoute({ if (body.allowEditPersonalInfo !== undefined) { values.allowEditPersonalInfo = body.allowEditPersonalInfo; } + if (body.allowEditOwnContent !== undefined) { + values.allowEditOwnContent = body.allowEditOwnContent; + } + if (body.editOwnContentMinutes !== undefined) { + values.editOwnContentMinutes = body.editOwnContentMinutes; + } + if (body.allowDeleteOwnContent !== undefined) { + values.allowDeleteOwnContent = body.allowDeleteOwnContent; + } + if (body.allowHideOwnContent !== undefined) { + values.allowHideOwnContent = body.allowHideOwnContent; + } if (body.allowUploadAvatar !== undefined) { values.allowUploadAvatar = body.allowUploadAvatar; diff --git a/packages/vitnode/src/components/confirm-action/content.tsx b/packages/vitnode/src/components/confirm-action/content.tsx index 2eca01aed4..9f760de054 100644 --- a/packages/vitnode/src/components/confirm-action/content.tsx +++ b/packages/vitnode/src/components/confirm-action/content.tsx @@ -18,7 +18,7 @@ export const ContentConfirmAction = ({ /** Defaults to `destructive`. */ submitVariant?: React.ComponentProps["variant"]; /** The confirm button's label. Defaults to a translated "Yes, confirm". */ - textSubmit?: string; + textSubmit?: React.ReactNode; }) => { const t = useTranslations("core.global.confirm_action"); const { setOpen } = useAlertDialog(); diff --git a/packages/vitnode/src/components/share-popover.tsx b/packages/vitnode/src/components/share-popover.tsx new file mode 100644 index 0000000000..e5a2fea1d9 --- /dev/null +++ b/packages/vitnode/src/components/share-popover.tsx @@ -0,0 +1,271 @@ +import { cn } from "cn"; +import { MailIcon, Share2Icon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { Button } from "@/components/ui/button"; +import { CopyButton } from "@/components/ui/copy-button"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@/components/ui/input-group"; +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "@/components/ui/popover"; +import { TooltipGroup, TooltipWithContent } from "@/components/ui/tooltip"; + +interface ShareNetwork { + brandColor?: string; + href: (link: { title: string; url: string }) => string; + iconPath: string; + name: string; +} + +const SHARE_NETWORKS: ShareNetwork[] = [ + { + name: "X", + iconPath: + "M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z", + href: ({ title, url }) => + `https://x.com/intent/post?${new URLSearchParams({ text: title, url })}`, + }, + { + name: "Bluesky", + brandColor: "#1185FE", + iconPath: + "M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z", + href: ({ title, url }) => + `https://bsky.app/intent/compose?${new URLSearchParams({ text: title ? `${title} ${url}` : url })}`, + }, + { + name: "Facebook", + brandColor: "#0866FF", + iconPath: + "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z", + href: ({ url }) => + `https://www.facebook.com/sharer/sharer.php?${new URLSearchParams({ u: url })}`, + }, + { + name: "Reddit", + brandColor: "#FF4500", + iconPath: + "M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z", + href: ({ title, url }) => + `https://www.reddit.com/submit?${new URLSearchParams({ title, url })}`, + }, + { + name: "LinkedIn", + brandColor: "#0A66C2", + iconPath: + "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z", + href: ({ url }) => + `https://www.linkedin.com/sharing/share-offsite/?${new URLSearchParams({ url })}`, + }, +]; + +const TILE_CLASS = + "focus-visible:ring-ring/50 flex aspect-square w-full items-center justify-center rounded-full outline-none transition-[scale,opacity] duration-150 ease-out hover:opacity-90 focus-visible:ring-3 active:scale-[0.96] motion-reduce:transition-none [&_svg]:size-5"; + +const emailHref = ({ title, url }: { title: string; url: string }) => + `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(url)}`; + +const canUseShareSheet = () => + typeof navigator !== "undefined" && typeof navigator.share === "function"; + +const ShareLinks = ({ + copyRef, + onShared, + title, + url, +}: { + copyRef: React.Ref; + onShared: () => void; + title: string; + url: string; +}) => { + const t = useTranslations("core.global.share"); + const absoluteUrl = React.useMemo( + () => new URL(url, window.location.href).href, + [url], + ); + const link = { title, url: absoluteUrl }; + + const openShareSheet = async () => { + try { + await navigator.share({ title: title || undefined, url: absoluteUrl }); + onShared(); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; + toast.error(t("failed")); + } + }; + + return ( + <> + + { + event.currentTarget.select(); + }} + readOnly + value={absoluteUrl} + /> + + + + + + +
        + {SHARE_NETWORKS.map(network => ( +
      • + + + + + +
      • + ))} +
      • + + + + +
      • +
      +
      + + {canUseShareSheet() ? ( + + ) : null} + + ); +}; + +/** + * A share menu anchored to its trigger: the link with a copy button, one-click + * sharing to X, Bluesky, Facebook, Reddit, LinkedIn and email, and the + * device's own share sheet where the browser has one. Universal; the link is + * resolved in the browser when the menu opens, so a root-relative `url` such + * as `/blog/article/hello#comment-x` is fine during SSR. + * + * @example + * ```tsx + * import { SharePopover } from "@vitnode/core/components/share-popover"; + * import { Button } from "@vitnode/core/components/ui/button"; + * + * + * + * ; + * ``` + */ +export const SharePopover = ({ + align = "start", + children, + description, + heading, + side = "bottom", + title = "", + url, +}: { + /** Popover alignment against the trigger. Defaults to `"start"`. */ + align?: React.ComponentProps["align"]; + /** The trigger, such as a `Button`; it must forward its ref and props. */ + children: React.ReactElement; + /** A line under the heading, such as what is being shared. */ + description?: React.ReactNode; + /** The popover heading. Defaults to the translated "Share". */ + heading?: React.ReactNode; + /** Popover side. Defaults to `"bottom"`. */ + side?: React.ComponentProps["side"]; + /** The text networks prefill next to the link, and the email subject. */ + title?: string; + /** The link to share: absolute, or relative to the current page. */ + url: string; +}) => { + const t = useTranslations("core.global.share"); + const [open, setOpen] = React.useState(false); + const copyRef = React.useRef(null); + + return ( + + + + + {heading ?? t("title")} + {description ? ( + + {description} + + ) : null} + + { + setOpen(false); + }} + title={title} + url={url} + /> + + + ); +}; diff --git a/packages/vitnode/src/components/tiptap/suggestion-popup.ts b/packages/vitnode/src/components/tiptap/suggestion-popup.ts index 1ff844bb31..95ea2afb46 100644 --- a/packages/vitnode/src/components/tiptap/suggestion-popup.ts +++ b/packages/vitnode/src/components/tiptap/suggestion-popup.ts @@ -5,7 +5,7 @@ import React from "react"; export const SUGGESTION_POPUP_WRAPPER_CLASS = "group/suggestion z-50"; export const SUGGESTION_POPUP_CLASS = - "bg-popover/80 text-popover-foreground ring-foreground/10 backdrop-blur-sm animate-in fade-in-0 zoom-in-95 w-72 rounded-lg shadow-md ring-1 duration-100 motion-reduce:animate-none origin-top-left rtl:origin-top-right group-data-[side=top]/suggestion:origin-bottom-left rtl:group-data-[side=top]/suggestion:origin-bottom-right"; + "bg-popover/90 text-popover-foreground ring-foreground/10 backdrop-blur-sm animate-in fade-in-0 zoom-in-95 w-72 rounded-lg shadow-md ring-1 duration-100 motion-reduce:animate-none origin-top-left rtl:origin-top-right group-data-[side=top]/suggestion:origin-bottom-left rtl:group-data-[side=top]/suggestion:origin-bottom-right"; export const mountSuggestionPopup = ( mount: SuggestionProps["mount"], diff --git a/packages/vitnode/src/components/ui/combobox.tsx b/packages/vitnode/src/components/ui/combobox.tsx index a508660579..90d29e005b 100644 --- a/packages/vitnode/src/components/ui/combobox.tsx +++ b/packages/vitnode/src/components/ui/combobox.tsx @@ -132,7 +132,7 @@ function ComboboxContent({ > - + diff --git a/packages/vitnode/src/components/ui/popover.tsx b/packages/vitnode/src/components/ui/popover.tsx index d9775625e2..4b8e630e89 100644 --- a/packages/vitnode/src/components/ui/popover.tsx +++ b/packages/vitnode/src/components/ui/popover.tsx @@ -44,7 +44,7 @@ function PopoverContent({ > ({}); - -/** - * A group of radio buttons (Base UI `RadioGroup`). The selected dot glides - * between its {@link RadioGroupItem}s. - */ -function RadioGroup({ - className, - children, - ...props -}: RadioGroupPrimitive.Props) { - const indicatorId = React.useId(); - const contextValue = React.useMemo(() => ({ indicatorId }), [indicatorId]); +/** A group of radio buttons (Base UI `RadioGroup`) holding {@link RadioGroupItem}s. */ +function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) { return ( - - - {children} - - - + /> ); } @@ -48,13 +20,10 @@ function RadioGroupItem({ className, ...props }: React.ComponentProps) { - const { indicatorId } = React.use(RadioGroupContext); - const shouldReduceMotion = useReducedMotion(); - return ( - } /> ); diff --git a/packages/vitnode/src/components/ui/select.tsx b/packages/vitnode/src/components/ui/select.tsx index d8f121e8de..dcc923ab35 100644 --- a/packages/vitnode/src/components/ui/select.tsx +++ b/packages/vitnode/src/components/ui/select.tsx @@ -97,7 +97,7 @@ function SelectContent({ ( + + + +); + const TooltipDelayContext = React.createContext(TOOLTIP_OPEN_DELAY); function TooltipProvider({ @@ -70,14 +84,15 @@ function TooltipContent({ > {children} - + @@ -154,7 +169,8 @@ function TooltipGroup({ > {payload} - diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 047e435e36..64bfa4ab09 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -6,6 +6,10 @@ export const CONTENT_EDITORIAL_FIELDS = ["version"] as const; export const CONTENT_VISIBILITY_FIELDS = ["hiddenAt", "hiddenBy"] as const; +export const CONTENT_THREAD_FIELDS = ["deletedAt", "deletedBy"] as const; + +export const CONTENT_HIDDEN_REASON_MAX_LENGTH = 500; + export const CONTENT_VISIBILITY_ACTIONS = ["hide", "unhide"] as const; export const CONTENT_VISIBILITY_FILTERS = ["hidden", "visible"] as const; diff --git a/packages/vitnode/src/content/define-reports.test.ts b/packages/vitnode/src/content/define-reports.test.ts index f481b823f0..7853a32f7c 100644 --- a/packages/vitnode/src/content/define-reports.test.ts +++ b/packages/vitnode/src/content/define-reports.test.ts @@ -40,6 +40,7 @@ describe("reports", () => { it.each([undefined, false] as const)("is off when %s", reports => { expect(define(reports).reports).toEqual({ enabled: false, + selfReport: false, snapshotFields: [], titleField: null, }); @@ -48,6 +49,7 @@ describe("reports", () => { it("snapshots the public reader-facing fields, minus the title", () => { expect(define(true).reports).toEqual({ enabled: true, + selfReport: false, snapshotFields: ["body", "summary"], titleField: "title", }); @@ -59,6 +61,7 @@ describe("reports", () => { .reports, ).toEqual({ enabled: true, + selfReport: false, snapshotFields: ["internalNote", "views"], titleField: "slug", }); diff --git a/packages/vitnode/src/content/define-reports.ts b/packages/vitnode/src/content/define-reports.ts index 41c0cba3e8..78acb1cd83 100644 --- a/packages/vitnode/src/content/define-reports.ts +++ b/packages/vitnode/src/content/define-reports.ts @@ -32,6 +32,7 @@ const DEFAULT_SNAPSHOT_KINDS = new Set([ ]); const TITLE_KINDS = new Set([ + "richText", "slug", "text", "textarea", @@ -39,11 +40,16 @@ const TITLE_KINDS = new Set([ const DISABLED: ResolvedContentReportsConfig = { enabled: false, + selfReport: false, snapshotFields: [], titleField: null, }; -const REPORT_OPTION_KEYS = new Set(["snapshotFields", "titleField"]); +const REPORT_OPTION_KEYS = new Set([ + "selfReport", + "snapshotFields", + "titleField", +]); export const resolveReports = ( id: string, @@ -62,7 +68,7 @@ export const resolveReports = ( if (reports !== true && (typeof reports !== "object" || reports === null)) { throw new ContentEngineError( - "reports is `true` or an options object with `titleField` and `snapshotFields`.", + "reports is `true` or an options object with `titleField`, `snapshotFields` and `selfReport`.", { contentTypeId: id }, ); } @@ -73,7 +79,7 @@ export const resolveReports = ( ); if (unknownKey) { throw new ContentEngineError( - `reports.${unknownKey} is not an option. Use \`titleField\` and \`snapshotFields\`.`, + `reports.${unknownKey} is not an option. Use \`titleField\`, \`snapshotFields\` and \`selfReport\`.`, { contentTypeId: id }, ); } @@ -92,7 +98,7 @@ export const resolveReports = ( const descriptor = fields[options.titleField]; if (!descriptor || !TITLE_KINDS.has(descriptor.kind)) { throw new ContentEngineError( - `reports.titleField "${options.titleField}" must be a text, textarea or slug field of this content type.`, + `reports.titleField "${options.titleField}" must be a text, textarea, slug or rich text field of this content type.`, { contentTypeId: id }, ); } @@ -139,5 +145,19 @@ export const resolveReports = ( .map(([name]) => name); } - return { enabled: true, snapshotFields, titleField }; + if ( + options.selfReport !== undefined && + typeof options.selfReport !== "boolean" + ) { + throw new ContentEngineError("reports.selfReport is `true` or `false`.", { + contentTypeId: id, + }); + } + + return { + enabled: true, + selfReport: options.selfReport ?? false, + snapshotFields, + titleField, + }; }; diff --git a/packages/vitnode/src/content/define-thread-search.ts b/packages/vitnode/src/content/define-thread-search.ts new file mode 100644 index 0000000000..e7942957d7 --- /dev/null +++ b/packages/vitnode/src/content/define-thread-search.ts @@ -0,0 +1,79 @@ +import type { + ContentFieldMap, + ContentSearchConfig, + ResolvedContentDeliveryConfig, + ResolvedContentPublicApiConfig, + ResolvedContentPublicWriteConfig, + ResolvedContentThreadConfig, +} from "./types"; + +import { + CONTENT_SEARCH_SLUG_PLACEHOLDER, + CONTENT_SEARCH_TEXT_KINDS, +} from "./const"; + +const DELIVERY_SLUG_SEGMENT = ":slug"; + +const searchTextKinds: ReadonlySet = new Set(CONTENT_SEARCH_TEXT_KINDS); + +const rootIsSearchable = (fields: ContentFieldMap, rootField: string) => { + const root = fields[rootField]; + if (root?.kind !== "relation" || root.self) return false; + + try { + return root.target().search.enabled; + } catch { + return false; + } +}; + +export const defaultThreadSearch = ({ + delivery, + fields, + publicApi, + publicWrite, + thread, +}: { + delivery: ResolvedContentDeliveryConfig; + fields: ContentFieldMap; + publicApi: ResolvedContentPublicApiConfig; + publicWrite: ResolvedContentPublicWriteConfig; + thread: ResolvedContentThreadConfig; +}): ContentSearchConfig | undefined => { + if (!thread.enabled || !delivery.enabled || !publicApi.enabled) { + return undefined; + } + if (!rootIsSearchable(fields, thread.rootField)) return undefined; + + const exposed = new Set(publicApi.fields); + const contentFields = Object.entries(fields) + .filter( + ([name, descriptor]) => + exposed.has(name) && + name !== publicApi.slugField && + searchTextKinds.has(descriptor.kind), + ) + .map(([name]) => name); + const [firstContentField, ...otherContentFields] = contentFields; + if (firstContentField === undefined) return undefined; + + const pathTemplate = delivery.path + .split("/") + .map(segment => + segment === DELIVERY_SLUG_SEGMENT + ? CONTENT_SEARCH_SLUG_PLACEHOLDER + : segment, + ) + .join("/"); + + const owner = publicWrite.ownerField; + const search: ContentSearchConfig = { + contentFields: [firstContentField, ...otherContentFields], + pathTemplate, + titleField: thread.rootField, + }; + + return owner !== null && fields[owner]?.kind === "user" + ? { ...search, authorField: owner } + : search; +}; diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index c4bb136b9a..62fc25743c 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -85,6 +85,7 @@ import { visibilityFields, } from "./define-shared"; import { resolveContentThread } from "./define-thread"; +import { defaultThreadSearch } from "./define-thread-search"; import { resolveContentDelivery } from "./delivery"; import { ContentEngineError } from "./errors"; import { resolveContentIndexes } from "./indexes"; @@ -587,6 +588,7 @@ export const defineContentType = < fields: sharedColumns, publication: publicationEnabled, tableName, + thread: thread !== undefined && thread !== false, visibility: visibilityEnabled, }); @@ -625,15 +627,6 @@ export const defineContentType = < publicApi: resolvedPublicApi, }); - const resolvedSearch = resolveSearch( - id, - fieldMap, - search, - resolvedPublicApi, - publicationEnabled, - Object.keys(localizedFields).length > 0, - ); - const resolvedEditorial = resolveEditorial( id, editorial, @@ -683,6 +676,22 @@ export const defineContentType = < publicWrite: resolvedPublicWrite, }); + const resolvedSearch = resolveSearch( + id, + fieldMap, + search ?? + defaultThreadSearch({ + delivery: resolvedDelivery, + fields: fieldMap, + publicApi: resolvedPublicApi, + publicWrite: resolvedPublicWrite, + thread: resolvedThread, + }), + resolvedPublicApi, + publicationEnabled, + Object.keys(localizedFields).length > 0, + ); + const resolvedDuplication = resolveDuplication( id, duplication, diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index dc87301755..7e3e251646 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -152,6 +152,7 @@ export { CONTENT_FILE_PLUGIN_SEPARATOR, CONTENT_FILTERABLE_FIELD_KINDS, CONTENT_HIDDEN_HEADER, + CONTENT_HIDDEN_REASON_MAX_LENGTH, CONTENT_INPUT_ISSUE_CODES, CONTENT_INVALID_INPUT_CODE, CONTENT_LOCALE_MAX_LENGTH, diff --git a/packages/vitnode/src/content/indexes.ts b/packages/vitnode/src/content/indexes.ts index 9f9ef3ff67..00d3c6a922 100644 --- a/packages/vitnode/src/content/indexes.ts +++ b/packages/vitnode/src/content/indexes.ts @@ -192,6 +192,7 @@ export const resolveContentIndexes = ({ fields, publication = false, tableName, + thread = false, visibility = false, }: { contentTypeId: string; @@ -199,6 +200,7 @@ export const resolveContentIndexes = ({ fields: ContentFieldMap; publication?: boolean; tableName: string; + thread?: boolean; visibility?: boolean; }): ResolvedContentIndex[] => { const seenNames = new Map(); @@ -262,6 +264,7 @@ export const resolveContentIndexes = ({ ? [named(tableName, { on: ["status", "publishedAt"] })] : []), ...(visibility ? [named(tableName, { on: ["hiddenBy"] })] : []), + ...(thread ? [named(tableName, { on: ["deletedBy"] })] : []), ]; const bySignature = new Map(); diff --git a/packages/vitnode/src/content/registry.test.ts b/packages/vitnode/src/content/registry.test.ts index a0552ac756..3abde86886 100644 --- a/packages/vitnode/src/content/registry.test.ts +++ b/packages/vitnode/src/content/registry.test.ts @@ -349,13 +349,21 @@ describe("withContentPermissions", () => { it("registers can_edit for a content type whose public writes include updates", () => { const merged = withContentPermissions({}, [entry(testTopicContentType)]); - expect(merged?.moderator?.[topicModule]).toEqual(["can_edit"]); + expect(merged?.moderator?.[topicModule]).toEqual([ + "can_edit", + "can_delete", + "can_hide", + ]); }); it("registers it for a content type mounted only through the public module", () => { const merged = withContentPermissions({}, [], [testTopicContentType]); - expect(merged?.moderator?.[topicModule]).toEqual(["can_edit"]); + expect(merged?.moderator?.[topicModule]).toEqual([ + "can_edit", + "can_delete", + "can_hide", + ]); expect(merged?.admin?.[topicModule]).toBeUndefined(); }); @@ -370,7 +378,6 @@ describe("withContentPermissions", () => { it.each([ ["a read-only public API", testForumContentType], - ["a create-only publicWrite", createOnly], ["no public API at all", testArticleContentType], ])("adds no moderator module for %s", (_label, definition) => { const merged = withContentPermissions({}, [entry(definition)]); @@ -378,6 +385,14 @@ describe("withContentPermissions", () => { expect(merged?.moderator).toBeUndefined(); }); + it("registers only can_delete for a create-only publicWrite", () => { + const merged = withContentPermissions({}, [entry(createOnly)]); + + expect(merged?.moderator?.[createOnly.permissionModule]).toEqual([ + "can_delete", + ]); + }); + it("passes the config through when a public-only content type has nothing to add", () => { const permissionStaff = { admin: { posts: ["can_view"] } }; @@ -957,24 +972,24 @@ describe("contentModeratorPermissionEntries", () => { it.each<[string, AnyContentTypeDefinition, unknown[]]>([ [ - "an unscoped can_edit in a container without policies", + "an unscoped can_edit and can_delete in a container without policies", testThreadContentType, - ["can_edit"], + ["can_edit", "can_delete"], ], [ - "scoped can_edit and can_approve in a container with policies", + "scoped can_edit, can_delete and can_approve in a container with policies", topic, - [scoped("can_edit"), scoped("can_approve")], + [scoped("can_edit"), scoped("can_delete"), scoped("can_approve")], ], [ "the same, scoped by an inherited container", reply, - [scoped("can_edit"), scoped("can_approve")], + [scoped("can_edit"), scoped("can_delete"), scoped("can_approve")], ], [ - "only can_approve when public writes cannot update", + "can_delete and can_approve when public writes cannot update", topicIn({}), - [scoped("can_approve")], + [scoped("can_delete"), scoped("can_approve")], ], ])("registers %s", (_label, definition, expected) => { expect(contentModeratorPermissionEntries(definition)).toEqual(expected); diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index 3c50e0b7a5..d227c43d56 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -423,9 +423,11 @@ export const contentPermissionEntries = ( /** * Moderators act from the public site. `can_edit` edits any record a member - * could edit; `can_approve` reviews submissions. In a container collection with - * policies both are scoped by that collection, so a grant names the containers - * it covers. + * could edit; `can_delete` deletes and `can_hide` hides and unhides any record + * members write (for a type with `reports`, the moderation entries add + * `can_hide` instead, behind `can_view_hidden`); `can_approve` reviews + * submissions. In a container collection with policies they are scoped by + * that collection, so a grant names the containers it covers. */ export const contentModeratorPermissionEntries = ( definition: AnyContentTypeDefinition, @@ -439,6 +441,14 @@ export const contentModeratorPermissionEntries = ( ...(definition.publicWrite.update.enabled ? [scoped(CONTENT_PERMISSIONS.edit)] : []), + ...(definition.publicWrite.enabled + ? [scoped(CONTENT_PERMISSIONS.delete)] + : []), + ...(definition.publicWrite.enabled && + definition.visibility.enabled && + !definition.reports.enabled + ? [scoped(CONTENT_PERMISSIONS.hide)] + : []), ...(scope && definition.publicWrite.enabled ? [scoped(CONTENT_PERMISSIONS.approve)] : []), diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index 7c5bfd7b56..023c2492ac 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -20,6 +20,7 @@ import type { ContentFieldDescriptor } from "../types"; import { CONTENT_ENUM_DEFAULT_LENGTH, + CONTENT_HIDDEN_REASON_MAX_LENGTH, CONTENT_PUBLICATION_STATUS_LENGTH, CONTENT_PUBLICATION_STATUSES, CONTENT_SLUG_DEFAULT_LENGTH, @@ -81,6 +82,17 @@ export const buildVisibilityColumns = ( onDelete: "set null", onUpdate: "cascade", }), + hiddenReason: varchar({ length: CONTENT_HIDDEN_REASON_MAX_LENGTH }), +}); + +export const buildThreadColumns = ( + userReference: ColumnReferenceThunk, +): Record => ({ + deletedAt: timestamp(), + deletedBy: integer().references(userReference, { + onDelete: "set null", + onUpdate: "cascade", + }), }); export const buildEditorialColumns = (): Record< diff --git a/packages/vitnode/src/content/server/content-actions-access.ts b/packages/vitnode/src/content/server/content-actions-access.ts new file mode 100644 index 0000000000..6f8c02792b --- /dev/null +++ b/packages/vitnode/src/content/server/content-actions-access.ts @@ -0,0 +1,121 @@ +import type { Context } from "hono"; + +import type { ContentId } from "../ids"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentVisibilityAction } from "../visibility"; +import type { ContentModel } from "./model"; + +import { resolveOwnContentPolicy } from "../../api/lib/own-content-policy"; +import { CONTENT_PERMISSIONS } from "../const"; +import { + canModerateContent, + resolveContentRowContainer, +} from "../containers/server/policy"; +import { isContentHidden } from "../visibility"; +import { + canEditContentType, + canModerateContentVisibility, +} from "./hidden-access"; +import { contentPublicWriteOwnerOf } from "./public-write-authorization"; + +interface ContentRecordAccessArgs< + TDefinition extends AnyContentTypeDefinition, +> { + model: ContentModel; + pluginId: string; + row: Record; +} + +const isOwnerOf = ( + c: Context, + definition: AnyContentTypeDefinition, + row: Record, +): boolean => { + const user = c.get("user"); + + return ( + user !== null && contentPublicWriteOwnerOf(definition, row) === user.id + ); +}; + +/** + * Whether the visitor may hide or unhide this record from the site. Moderators + * (`can_hide`) may do both to any record. Its author may when one of their roles + * has "Allow hiding own content" - and may unhide only what they hid + * themselves, never what a moderator hid. Without `action`, the one the + * record's state calls for. Server-only. + */ +export const canHideContentRecord = async < + TDefinition extends AnyContentTypeDefinition, +>( + c: Context, + { + action, + model, + pluginId, + row, + }: ContentRecordAccessArgs & { + action?: ContentVisibilityAction; + }, +): Promise => { + const { definition } = model; + const user = c.get("user"); + if (!user || !definition.visibility.enabled) return false; + + const itemId = row.id as ContentId; + if (await canModerateContentVisibility(c, { itemId, model, pluginId })) { + return true; + } + if (!isOwnerOf(c, definition, row)) return false; + if (!(await resolveOwnContentPolicy(c, user)).hide) return false; + + const unhiding = + (action ?? (isContentHidden(row) ? "unhide" : "hide")) === "unhide"; + + return unhiding ? row.hiddenBy === user.id : true; +}; + +/** + * Whether the visitor may delete this record from the site: a moderator with + * `can_delete` for the record's container, staff with the AdminCP + * `can_delete`, or its author when one of their roles has "Allow deleting own + * content". Server-only. + */ +export const canDeleteContentRecord = async < + TDefinition extends AnyContentTypeDefinition, +>( + c: Context, + { model, pluginId, row }: ContentRecordAccessArgs, +): Promise => { + const { definition } = model; + const user = c.get("user"); + if (!user || !definition.publicWrite.enabled) return false; + if (row.deletedAt !== null && row.deletedAt !== undefined) return false; + + const container = await resolveContentRowContainer( + c, + c.get("db"), + definition, + row, + ); + if ( + (await canModerateContent(c, { + container, + definition, + permission: CONTENT_PERMISSIONS.delete, + pluginId, + })) || + (await canEditContentType(c, { + definition, + permission: CONTENT_PERMISSIONS.delete, + pluginId, + })) + ) { + return true; + } + + return ( + isOwnerOf(c, definition, row) && + (await resolveOwnContentPolicy(c, user)).delete + ); +}; diff --git a/packages/vitnode/src/content/server/hidden-access.ts b/packages/vitnode/src/content/server/hidden-access.ts index 0f353e752c..b7df391c71 100644 --- a/packages/vitnode/src/content/server/hidden-access.ts +++ b/packages/vitnode/src/content/server/hidden-access.ts @@ -17,19 +17,24 @@ import { parseContentId } from "../ids"; import { contentViewableHiddenCondition } from "./hidden-list"; import { toAuthorIds } from "./search-document"; -const canEditContentType = async ( +export const canEditContentType = async ( c: Context, { definition, + permission = CONTENT_PERMISSIONS.edit, pluginId, - }: { definition: AnyContentTypeDefinition; pluginId: string }, + }: { + definition: AnyContentTypeDefinition; + permission?: string; + pluginId: string; + }, ): Promise => { const user = c.get("user"); if (!user) return false; return await checkStaffPermissionOfUser(c, user, { module: definition.permissionModule, - permission: CONTENT_PERMISSIONS.edit, + permission, plugin: pluginId, type: "admin", }); @@ -70,18 +75,24 @@ export const contentAuthorIds = async < return toAuthorIds(row?.value); }; -const canModeratorViewHidden = async < +const hasModeratorPermission = async < TDefinition extends AnyContentTypeDefinition, >( c: Context, { itemId, model, + permission, pluginId, - }: { itemId: ContentId; model: ContentModel; pluginId: string }, + }: { + itemId: ContentId; + model: ContentModel; + permission: string; + pluginId: string; + }, ): Promise => { const user = c.get("user"); - if (!user || !model.definition.reports.enabled) return false; + if (!user) return false; let resource: undefined | { family: string; id: string }; if (model.moderationScope) { @@ -107,13 +118,63 @@ const canModeratorViewHidden = async < return await checkStaffPermissionOfUser(c, user, { module: model.definition.permissionModule, - permission: MODERATION_PERMISSIONS.viewHidden, + permission, plugin: pluginId, resource, type: "moderator", }); }; +const canModeratorViewHidden = async < + TDefinition extends AnyContentTypeDefinition, +>( + c: Context, + args: { + itemId: ContentId; + model: ContentModel; + pluginId: string; + }, +): Promise => + args.model.definition.reports.enabled && + (await hasModeratorPermission(c, { + ...args, + permission: MODERATION_PERMISSIONS.viewHidden, + })); + +/** + * Whether the visitor may hide and unhide any record of this type from the + * site: a moderator with `can_hide` on the content type (within its scope), or + * staff with the AdminCP `can_hide` permission. Needs `visibility`. + * Server-only. + */ +export const canModerateContentVisibility = async < + TDefinition extends AnyContentTypeDefinition, +>( + c: Context, + { + itemId, + model, + pluginId, + }: { itemId: ContentId; model: ContentModel; pluginId: string }, +): Promise => { + const { definition } = model; + if (!definition.visibility.enabled) return false; + + return ( + (await canEditContentType(c, { + definition, + permission: CONTENT_PERMISSIONS.hide, + pluginId, + })) || + (await hasModeratorPermission(c, { + itemId, + model, + permission: MODERATION_PERMISSIONS.hide, + pluginId, + })) + ); +}; + const canModeratorViewAllHidden = async < TDefinition extends AnyContentTypeDefinition, >( @@ -161,6 +222,22 @@ const canViewHiddenContent = async < return await canModeratorViewHidden(c, { itemId, model, pluginId }); }; +export const canViewHiddenContentRecord = async ( + c: Context, + { contentTypeId, itemId }: { contentTypeId: string; itemId: ContentId }, +): Promise => { + const entry = (c.get("core")?.contentModels ?? []).find( + candidate => candidate.model.definition.id === contentTypeId, + ); + if (!entry?.model.definition.visibility.enabled) return false; + + return await canViewHiddenContent(c, { + itemId, + model: entry.model, + pluginId: entry.pluginId, + }); +}; + export const findViewableHiddenBySlug = async < TDefinition extends AnyContentTypeDefinition, >( @@ -259,3 +336,28 @@ export const contentSearchHiddenAccess = async ( return { authorId: user.id, itemTypes: editable.flat() }; }; + +/** + * Which hidden records of a thread the visitor may read: every one for + * editors and unscoped moderators (`all`), otherwise the ones they own. A + * thread read with it is personal - never cache it. `undefined` for guests and + * types without `visibility`. Server-only. + */ +export const contentThreadHiddenAccess = async < + TDefinition extends AnyContentTypeDefinition, +>( + c: Context, + { model, pluginId }: { model: ContentModel; pluginId: string }, +): Promise => { + const user = c.get("user"); + if (!user || !model.definition.visibility.enabled) return undefined; + + return { + all: + (await canEditContentType(c, { + definition: model.definition, + pluginId, + })) || (await canModeratorViewAllHidden(c, { model, pluginId })), + authorId: user.id, + }; +}; diff --git a/packages/vitnode/src/content/server/notifications/content-notifications.postgres.test.ts b/packages/vitnode/src/content/server/notifications/content-notifications.postgres.test.ts index 76421e5f6e..f6280038bb 100644 --- a/packages/vitnode/src/content/server/notifications/content-notifications.postgres.test.ts +++ b/packages/vitnode/src/content/server/notifications/content-notifications.postgres.test.ts @@ -69,35 +69,40 @@ const REPLY_TYPE = "ntest.comment_reply"; const DECISION_TYPE = "ntest.comment_decision"; const TOPIC_TYPE = testTopicPublishedNotification.id; -const commentNotifications = buildContentNotifications({ - content: testNoticeCommentContentType, - root: testNoticeTopicContentType, - rules: { - replies: { - id: REPLY_TYPE, - preset: "threadReply", - grouping: { windowMinutes: 60 }, - recipients: { authorField: "author", rootAuthorField: "author" }, - messages: { - label: "ntest.reply.label", - comment: "ntest.reply.comment", - reply: "ntest.reply.reply", +/** Without `rootAuthorField` the rule covers replies only, as on Blog. */ +const commentNotifications = (rootAuthorField?: "author") => + buildContentNotifications({ + content: testNoticeCommentContentType, + root: testNoticeTopicContentType, + rules: { + replies: { + id: REPLY_TYPE, + preset: "threadReply", + grouping: { windowMinutes: 60 }, + recipients: { authorField: "author", rootAuthorField }, + messages: { + label: "ntest.reply.label", + comment: "ntest.reply.comment", + reply: "ntest.reply.reply", + }, + target: ({ content, root }) => + `/topics/${root.id}#comment-${content.id}`, }, - target: ({ content, root }) => `/topics/${root.id}#comment-${content.id}`, - }, - decisions: { - id: DECISION_TYPE, - preset: "moderationDecision", - messages: { - label: "ntest.decision.label", - approved: "ntest.decision.approved", - rejected: "ntest.decision.rejected", + decisions: { + id: DECISION_TYPE, + preset: "moderationDecision", + messages: { + label: "ntest.decision.label", + approved: "ntest.decision.approved", + rejected: "ntest.decision.rejected", + }, + target: ({ content, decision }) => + decision.status === "approved" + ? `/comments/${content.id}` + : "/pending", }, - target: ({ content, decision }) => - decision.status === "approved" ? `/comments/${content.id}` : "/pending", }, - }, -}); + }); const topicNotifications = buildContentNotifications({ content: testNoticeTopicContentType, @@ -114,31 +119,40 @@ const topicNotifications = buildContentNotifications({ }, }); -const plugin = buildApiPlugin({ - manifest: testManifest(PLUGIN_ID), - modules: [], - contentNotifications: [commentNotifications, topicNotifications], -}); - const contentModels = [noticeTopics, noticeComments, noticeQuiet].map( model => ({ model, pluginId: PLUGIN_ID }), ); +const registriesOf = (comments: ReturnType) => { + const plugin = buildApiPlugin({ + manifest: testManifest(PLUGIN_ID), + modules: [], + contentNotifications: [comments, topicNotifications], + }); + + return { + contentNotifications: createContentNotificationRegistry( + (plugin.contentNotifications ?? []).map(config => ({ + config, + pluginId: PLUGIN_ID, + })), + contentModels, + ), + notifications: createNotificationRegistry( + (plugin.notificationTypes ?? []).map(definition => ({ + definition, + pluginId: PLUGIN_ID, + })), + ), + }; +}; + +const threadRules = registriesOf(commentNotifications("author")); +const repliesOnlyRules = registriesOf(commentNotifications()); + const core = { contentModels, - contentNotifications: createContentNotificationRegistry( - (plugin.contentNotifications ?? []).map(config => ({ - config, - pluginId: PLUGIN_ID, - })), - contentModels, - ), - notifications: createNotificationRegistry( - (plugin.notificationTypes ?? []).map(definition => ({ - definition, - pluginId: PLUGIN_ID, - })), - ), + ...threadRules, queue: [contentNotificationsQueueTask, notificationsFanoutTask].map(task => ({ ...task, module: "test", @@ -386,6 +400,7 @@ describe.skipIf(!POSTGRES_TEST_URL)( beforeEach(async () => { vi.restoreAllMocks(); + Object.assign(core, threadRules); await schema.client.unsafe( `truncate ntest_comments, ntest_topics, ntest_quiet, core_queue, core_notification_events, core_notifications, core_notification_receipts, @@ -669,6 +684,7 @@ describe.skipIf(!POSTGRES_TEST_URL)( topic: null | number; }, { recipients: number[]; relation: "comment" | "reply" }[], + rules?: typeof threadRules, ] >([ [ @@ -714,7 +730,20 @@ describe.skipIf(!POSTGRES_TEST_URL)( { actor: null, comment: null, topic: TOPIC_AUTHOR }, [{ recipients: [TOPIC_AUTHOR], relation: "comment" }], ], - ])("%s", async (_, authors, expected) => { + [ + "without a root author field, a comment notifies nobody", + { comment: COMMENTER, topic: TOPIC_AUTHOR }, + [], + repliesOnlyRules, + ], + [ + "without a root author field, only the parent's author hears about a reply", + { comment: REPLIER, parent: COMMENTER, topic: TOPIC_AUTHOR }, + [{ recipients: [COMMENTER], relation: "reply" }], + repliesOnlyRules, + ], + ])("%s", async (_, authors, expected, rules = threadRules) => { + Object.assign(core, rules); const topic = await createTopic({ author: authors.topic }); const parent = authors.parent === undefined diff --git a/packages/vitnode/src/content/server/notifications/define.test.ts b/packages/vitnode/src/content/server/notifications/define.test.ts index 1b6c83f84b..adae3e798d 100644 --- a/packages/vitnode/src/content/server/notifications/define.test.ts +++ b/packages/vitnode/src/content/server/notifications/define.test.ts @@ -5,7 +5,9 @@ import { describe, expect, it, vi } from "vitest"; import type { EnvVitNode } from "@/api/middlewares/global.middleware"; import { buildApiPlugin } from "@/api/lib/plugin"; +import { defineContentType } from "@/content/define"; import { ContentEngineError } from "@/content/errors"; +import { field } from "@/content/fields"; import { noticeComments, noticeQuiet, @@ -142,6 +144,28 @@ describe("buildContentNotifications", () => { }), /"ntest.topic" has no field "owner"/, ], + [ + "a root author field with several users", + () => + buildContentNotifications({ + content: testNoticeCommentContentType, + root: defineContentType({ + id: "ntest.article", + tableName: "ntest_articles", + fields: { authors: field.user({ multiple: true }) }, + }), + rules: { + replies: { + ...replyRule(), + recipients: { + authorField: "author", + rootAuthorField: "authors" as never, + }, + }, + }, + }), + /"ntest.article.authors" must be a to-one, shared `field.user\(\)`/, + ], [ "threadReply on a content type without a thread", () => diff --git a/packages/vitnode/src/content/server/notifications/process.ts b/packages/vitnode/src/content/server/notifications/process.ts index 1b259e31b5..e154ee0778 100644 --- a/packages/vitnode/src/content/server/notifications/process.ts +++ b/packages/vitnode/src/content/server/notifications/process.ts @@ -74,13 +74,17 @@ const titleFieldOf = ( const recipientsFrom = ( ids: readonly (null | number | undefined)[], exclude: readonly (null | number)[], -): number[] => [ - ...new Set( - ids - .map(userIdOf) - .filter((id): id is number => id !== null && !exclude.includes(id)), - ), -]; +): number[] => { + const excluded = new Set(exclude); + + return [ + ...new Set( + ids + .map(userIdOf) + .filter((id): id is number => id !== null && !excluded.has(id)), + ), + ]; +}; const warn = async (c: Context, message: string) => { await c.get("log").warn(`${CONTENT_NOTIFICATIONS_LOG_PREFIX} ${message}`); diff --git a/packages/vitnode/src/content/server/parent-visibility.ts b/packages/vitnode/src/content/server/parent-visibility.ts index 713195ef13..560db9bd4b 100644 --- a/packages/vitnode/src/content/server/parent-visibility.ts +++ b/packages/vitnode/src/content/server/parent-visibility.ts @@ -20,6 +20,7 @@ interface ContentParentVisibility { selection: Record; visible: >( rows: TRow[], + options?: { hiddenParents?: boolean }, ) => Promise; } @@ -71,7 +72,12 @@ export const createContentParentVisibility = ( selection: { [CONTENT_PARENT_KEY]: columns[parent.field] }, visible: async >( rows: TRow[], + options?: { hiddenParents?: boolean }, ): Promise => { + const parentPublication = publicationColumns( + parent.target, + parentColumns, + ); const ids = [ ...new Set( rows.map(row => row[CONTENT_PARENT_KEY]).filter(isParentReference), @@ -89,7 +95,9 @@ export const createContentParentVisibility = ( and( inArray(parentId, ids), publishedCondition( - publicationColumns(parent.target, parentColumns), + options?.hiddenParents + ? { ...parentPublication, hiddenAt: undefined } + : parentPublication, ), ), ) diff --git a/packages/vitnode/src/content/server/public-permissions.ts b/packages/vitnode/src/content/server/public-permissions.ts index 8e17d4d80e..e0e4ff1446 100644 --- a/packages/vitnode/src/content/server/public-permissions.ts +++ b/packages/vitnode/src/content/server/public-permissions.ts @@ -16,6 +16,10 @@ import { resolveContentRowContainer, } from "../containers/server/policy"; import { contentIdKey, parseContentId } from "../ids"; +import { + canDeleteContentRecord, + canHideContentRecord, +} from "./content-actions-access"; import { authorizeContentPublicWrite, contentPublicWriteOwnerOf, @@ -24,15 +28,29 @@ import { isContentBaseRowPublic } from "./visibility"; /** What the current visitor may do with one record. */ export interface ContentViewerPermissions { + /** + * `POST /{id}/delete` will accept this visitor: a moderator with + * `can_delete`, or the author when one of their roles allows deleting own + * content. + */ + canDelete: boolean; /** * `GET /{id}/edit` and `PATCH /{id}` will accept this visitor: the owner * while the container's policy allows it, or a moderator with `can_edit`. */ canEdit: boolean; + /** + * `POST /{id}/hide` (or `/unhide` for a hidden record) will accept this + * visitor: a moderator with `can_hide`, or the author when one of their roles + * allows hiding own content - who may only unhide what they hid themselves. + * Only for published records of a type with `visibility`. + */ + canHide: boolean; /** * A Report button makes sense: the type has `reports`, the record is public - * and the visitor did not write it. Guests get `true`; the report dialog - * asks them to sign in. + * and the visitor did not write it - unless `reports.selfReport` lets + * authors report their own. Guests get `true`; the report dialog asks them + * to sign in. */ canReport: boolean; } @@ -100,11 +118,26 @@ export const resolveContentViewerPermissions = async ( canEdit = decision.allowed; } - if (!isPublic && !canEdit) return null; + const isPublished = isContentBaseRowPublic({ ...row, hiddenAt: null }); + const canHide = + isPublished && + (await canHideContentRecord(c, { model, pluginId, row })); + const canDelete = await canDeleteContentRecord(c, { + model, + pluginId, + row, + }); + + if (!isPublic && !canEdit && !canHide && !canDelete) return null; return { + canDelete, canEdit, - canReport: definition.reports.enabled && isPublic && !isOwner, + canHide, + canReport: + definition.reports.enabled && + isPublic && + (!isOwner || definition.reports.selfReport), id, }; }), @@ -124,7 +157,7 @@ export const resolveContentViewerPermissions = async ( /** * `GET /{publicApi.path}/permissions?ids=1,2,3`: what the current visitor may * do with each record, as `can*` flags. Mounted for content types with - * `publicWrite.update` or `reports`. At most `CONTENT_PUBLIC_MAX_PAGE_SIZE` + * `publicWrite`, `reports` or `visibility`. At most `CONTENT_PUBLIC_MAX_PAGE_SIZE` * distinct ids per request - `useContentPermissions` batches for you. * Personal, so never cached. */ @@ -143,7 +176,7 @@ export const contentPermissionsRoute = < route: { method: "get", path: "/permissions", - description: `What the current visitor may do with each listed ${name}: edit it, report it`, + description: `What the current visitor may do with each listed ${name}: edit, delete, hide or report it`, request: { query: z.object({ ids: z @@ -159,7 +192,9 @@ export const contentPermissionsRoute = < schema: z.object({ items: z.array( z.object({ + canDelete: z.boolean(), canEdit: z.boolean(), + canHide: z.boolean(), canReport: z.boolean(), id: schemas.params.shape.id, }), diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index 3edc6f03b9..b39639c765 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -47,7 +47,9 @@ import { publicOrderableColumns } from "../registry"; import { buildContentDeliveryRoutes } from "./delivery-routes"; import { resolveContentPublicRowFiles } from "./files"; import { + canViewHiddenContentRecord, contentListHiddenAccess, + contentThreadHiddenAccess, findViewableHiddenBySlug, } from "./hidden-access"; import { findContentLanguage } from "./language-resolver"; @@ -64,6 +66,10 @@ import { contentPublicSelection, createContentPublicProjector, } from "./public-service"; +import { + contentPublicDeleteRoute, + contentPublicVisibilityRoutes, +} from "./public-visibility-routes"; import { contentPublicWriteRoutes } from "./public-write-routes"; import { contentLocaleHeaders, @@ -519,7 +525,12 @@ export const buildContentPublicRoutes = < }); const threadNode = z.object({ + deleted: z + .object({ byAuthor: z.boolean(), createdAt: z.string() }) + .nullable(), depth: z.number(), + hidden: z.boolean(), + hiddenReason: z.string().nullable(), id: schemas.params.shape.id, parentId: schemas.params.shape.id.nullable(), record: schemas.publicSelectObject.nullable(), @@ -576,22 +587,48 @@ export const buildContentPublicRoutes = < const rootId = target ? parseContentId(target.idStrategy, root) : null; // An existence check: `select: []` reads the same published, visible row // without hydrating a single field of it. - const rootRecord = - rootId === null - ? null - : await rootModel?.publicService?.(c).findById(rootId, { - select: [], - }); - if (!rootRecord || rootId === null) { + const findReadableRoot = async () => { + const rootService = rootModel?.publicService?.(c); + if (rootId === null || !target || !rootService) return null; + + if (await rootService.findById(rootId, { select: [] })) { + return { hidden: false }; + } + + const hidden = await rootService.findById(rootId, { + hidden: true, + select: [], + }); + + return hidden && + (await canViewHiddenContentRecord(c, { + contentTypeId: target.id, + itemId: rootId, + })) + ? { hidden: true } + : null; + }; + const readable = await findReadableRoot(); + if (!readable || rootId === null) { throw new HTTPException(404, { message: "Thread not found." }); } const read = service(c).findManyByIds; + const hiddenAccess = await contentThreadHiddenAccess(c, { + model, + pluginId, + }); const page = await readContentThread(c, model, { after, first, - project: async ids => { - const rows = read ? await read(ids as never) : []; + hiddenAccess, + project: async (ids, { hiddenIds }) => { + const rows = read + ? await read(ids as never, { + hidden: hiddenIds.length > 0, + hiddenParents: readable.hidden, + }) + : []; return new Map( rows.map(row => [ @@ -603,12 +640,29 @@ export const buildContentPublicRoutes = < root: rootId, }); + const body = { + ...page, + items: page.items.map(({ deleted, ...item }) => ({ + ...item, + deleted: deleted + ? { + byAuthor: deleted.byAuthor, + createdAt: deleted.createdAt.toISOString(), + } + : null, + })), + }; + + if (hiddenAccess || readable.hidden) { + return c.json(body, 200, { "Cache-Control": "private, no-store" }); + } + setPageCacheTags(c, [ ...contentListPageCacheTags(definition), ...(target ? contentListPageCacheTags(target) : []), ]); - return c.json(page, 200); + return c.json(body, 200); }, }); @@ -702,9 +756,18 @@ export const buildContentPublicRoutes = < ? buildContentDeliveryRoutes(model, { pluginId }) : []), ...(definition.thread.enabled ? [thread] : []), - ...(definition.publicWrite.update.enabled || definition.reports.enabled + ...(definition.publicWrite.enabled || + definition.reports.enabled || + definition.visibility.enabled ? [contentPermissionsRoute(model, { pluginId })] : []), + ...(definition.visibility.enabled && + (definition.publicWrite.enabled || definition.reports.enabled) + ? contentPublicVisibilityRoutes(model, { pluginId }) + : []), + ...(definition.publicWrite.enabled + ? [contentPublicDeleteRoute(model, { pluginId })] + : []), detail, ...contentPublicWriteRoutes(model, { pluginId }), ]; diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index 74cbc510d7..24dbb89906 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -6,7 +6,7 @@ import type { } from "drizzle-orm/pg-core"; import type { Context } from "hono"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, or } from "drizzle-orm"; import type { PaginationCursorColumn } from "../../api/lib/with-pagination"; import type { ContentIdOf } from "../ids"; @@ -144,6 +144,18 @@ export interface ContentPublicService { */ findManyByIds?: ( ids: readonly ContentIdOf[], + options?: { + /** + * Also read published records that are hidden. Only for ids the caller + * already cleared for this reader, such as a thread's moderators. + */ + hidden?: boolean; + /** + * Also keep records whose parent (a thread's root) is hidden. Only once + * the caller cleared that parent for this reader. + */ + hiddenParents?: boolean; + }, ) => Promise[]>; } @@ -431,7 +443,8 @@ export const createContentPublicService = < parent ? { ...selection, ...parent.selection } : selection; const keepVisible = async >( rows: TRow[], - ): Promise => (parent ? await parent.visible(rows) : rows); + options?: { hiddenParents?: boolean }, + ): Promise => (parent ? await parent.visible(rows, options) : rows); const primaryCursor = columns.id as PaginationCursorColumn; const searchColumns = publicApi.searchableFields.map(name => columns[name]); const orderable = publicOrderableColumns(definition); @@ -492,20 +505,22 @@ export const createContentPublicService = < findBySlug: async (slug, options) => await readOne(eq(columns[publicApi.slugField], slug), options), - findManyByIds: async ids => { + findManyByIds: async (ids, options) => { if (ids.length === 0) return []; + const readable = options?.hidden + ? or( + publishedCondition(published), + contentPublicReadCondition(published, { hidden: true }), + ) + : publishedCondition(published); const rows = await keepVisible( await c .get("db") .select(withParent(detail.selection)) .from(table) - .where( - and( - publishedCondition(published), - inArray(primaryCursor, [...ids]), - ), - ), + .where(and(readable, inArray(primaryCursor, [...ids]))), + { hiddenParents: options?.hiddenParents }, ); return (await detail.hydrate(rows)).map(detail.project); diff --git a/packages/vitnode/src/content/server/public-visibility-routes.ts b/packages/vitnode/src/content/server/public-visibility-routes.ts new file mode 100644 index 0000000000..b9ed4c1d6e --- /dev/null +++ b/packages/vitnode/src/content/server/public-visibility-routes.ts @@ -0,0 +1,343 @@ +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import type { ContentId } from "../ids"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentVisibilityAction } from "../visibility"; +import type { AnyContentModel, ContentModel } from "./model"; + +import { buildRoute } from "../../api/lib/route"; +import { contentTypeName } from "../admin/labels"; +import { CONTENT_HIDDEN_REASON_MAX_LENGTH } from "../const"; +import { contentIdKey } from "../ids"; +import { resolveContentActor } from "./actor"; +import { + canDeleteContentRecord, + canHideContentRecord, +} from "./content-actions-access"; +import { contentEditorialEffects } from "./editorial-effects"; +import { emitContentEvent } from "./emit"; +import { withHttpErrors } from "./http-errors"; +import { resetContentLiveRecord } from "./live/hooks"; +import { contentPublicStateOf } from "./page-cache"; +import { visibilityAction } from "./report-target"; +import { contentIdentifier, jsonBody, readOptionalJson } from "./route-helpers"; +import { syncContentSearch } from "./search-sync"; +import { isContentBaseRowPublic } from "./visibility"; + +const readRow = async ( + c: Context, + model: { columns: { id: unknown }; table: PgTable }, + id: ContentId, +): Promise | undefined> => { + const [row] = (await c + .get("db") + .select() + .from(model.table) + .where(eq(model.columns.id as never, id)) + .limit(1)) as Record[]; + + return row; +}; + +/** + * `POST /{publicApi.path}/{id}/hide` and `.../unhide`: hide or show a published + * record from the site, through the same editorial path a ModCP report action + * takes - revisions, search, caches and events included. Moderators may do it + * to any record; authors to their own when a role allows it, and they may only + * unhide what they hid themselves. Mounted for content types with + * `visibility`. Server-only. + */ +export const contentPublicVisibilityRoutes = < + TDefinition extends AnyContentTypeDefinition, + P extends string, +>( + model: ContentModel, + { pluginId }: { pluginId: P }, +) => { + const { definition, schemas } = model; + const anyModel = model as unknown as AnyContentModel; + const name = contentTypeName(definition.id); + + const response = { + 200: { + content: { + "application/json": { + schema: z.object({ + changed: z.boolean(), + hidden: z.boolean(), + id: schemas.params.shape.id, + }), + }, + }, + description: `The ${name}'s visibility now`, + }, + 401: { description: "Not signed in" }, + 403: { description: `Not allowed to change this ${name}` }, + 404: { description: `${name} not found, or not published` }, + }; + + const changeVisibility = async ( + c: Context, + action: ContentVisibilityAction, + reason: null | string, + ) => { + const user = c.get("user"); + if (!user) throw new HTTPException(401, { message: "Sign in first." }); + + const id = contentIdentifier(c, definition); + const row = await readRow(c, model, id); + if (!row || !isContentBaseRowPublic({ ...row, hiddenAt: null })) { + throw new HTTPException(404, { message: `${name} not found.` }); + } + if (!(await canHideContentRecord(c, { action, model, pluginId, row }))) { + throw new HTTPException(403, { + message: `You cannot ${action} this ${name}.`, + }); + } + + const table: PgTable = model.table; + const columns = model.columns as Record; + const { run } = visibilityAction(anyModel, pluginId, action); + const outcome = await c.get("db").transaction(async tx => { + const result = await run(c, { + actorUserId: user.id, + targetId: contentIdKey(id), + tx, + }); + if (result && reason) { + await tx + .update(table) + .set({ hiddenReason: reason }) + .where(eq(columns.id, id as never)); + } + + return result; + }); + if (!outcome) { + throw new HTTPException(404, { message: `${name} not found.` }); + } + await outcome.effects(); + + return c.json( + { changed: outcome.changed, hidden: action === "hide", id }, + 200, + { "Cache-Control": "private, no-store" }, + ); + }; + + const hideBody = z.object({ + reason: z.string().trim().max(CONTENT_HIDDEN_REASON_MAX_LENGTH).optional(), + }); + + const hide = buildRoute({ + pluginId, + route: { + method: "post", + path: "/{id}/hide", + description: `Hide a published ${name} from the site, as a moderator or its author, with an optional reason its author and the moderators see`, + request: { + params: schemas.params, + body: { ...jsonBody(hideBody), required: false }, + }, + responses: response, + }, + handler: async c => { + const { reason = "" } = await readOptionalJson(c, hideBody); + + return await changeVisibility(c, "hide", reason === "" ? null : reason); + }, + }); + + const unhide = buildRoute({ + pluginId, + route: { + method: "post", + path: "/{id}/unhide", + description: `Show a hidden ${name} on the site again, as a moderator or the author who hid it`, + request: { params: schemas.params }, + responses: response, + }, + handler: async c => await changeVisibility(c, "unhide", null), + }); + + return [hide, unhide] as const; +}; + +/** + * `POST /{publicApi.path}/{id}/delete`: delete a record from the site, through + * the same path the AdminCP takes - a final revision for an editorial type, + * then search, caches, live editing and events. A thread reply that still has + * answers is kept as an anonymous placeholder instead (`deletedAt`), so its + * answers stay in place; deleting its last answer removes the placeholder + * too. Moderators with `can_delete` may delete any record; authors their own + * when a role allows it. Mounted for content types with `publicWrite`. + * Server-only. + */ +export const contentPublicDeleteRoute = < + TDefinition extends AnyContentTypeDefinition, + P extends string, +>( + model: ContentModel, + { pluginId }: { pluginId: P }, +) => { + const { definition, schemas } = model; + const name = contentTypeName(definition.id); + + return buildRoute({ + pluginId, + route: { + method: "post", + path: "/{id}/delete", + description: `Delete a ${name}, as a moderator or its author`, + request: { params: schemas.params }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ id: schemas.params.shape.id }), + }, + }, + description: `${name} deleted`, + }, + 401: { description: "Not signed in" }, + 403: { description: `Not allowed to delete this ${name}` }, + 404: { description: `${name} not found` }, + 409: { description: "Still referenced by other content" }, + }, + }, + handler: async c => { + const user = c.get("user"); + if (!user) throw new HTTPException(401, { message: "Sign in first." }); + + const id = contentIdentifier(c, definition); + const row = await readRow(c, model, id); + if (!row) throw new HTTPException(404, { message: `${name} not found.` }); + if (!(await canDeleteContentRecord(c, { model, pluginId, row }))) { + throw new HTTPException(403, { + message: `You cannot delete this ${name}.`, + }); + } + + const { parentField } = definition.thread; + const table: PgTable = model.table; + const columns = model.columns as Record; + const parentColumn = definition.thread.enabled + ? columns[parentField] + : undefined; + const hasReplies = async (itemId: unknown): Promise => { + if (!parentColumn) return false; + const [reply] = await c + .get("db") + .select({ id: columns.id }) + .from(table) + .where(eq(parentColumn, itemId as never)) + .limit(1); + + return reply !== undefined; + }; + + const removeRow = async (target: Record) => { + const targetId = target.id as ContentId; + if (definition.editorial.enabled && model.editorialService) { + const editorial = model.editorialService(c, { pluginId }); + const result = await withHttpErrors( + "delete", + async () => + await editorial.delete(targetId as never, { + actor: resolveContentActor(c), + expectedVersion: + typeof target.version === "number" ? target.version : 0, + }), + { + contentTypeId: definition.id, + itemId: targetId, + structured: true, + }, + ); + if (!result) return; + await contentEditorialEffects(c, definition, result, { + model, + pluginId, + }); + await resetContentLiveRecord( + c, + { contentTypeId: definition.id, itemId: targetId }, + "deleted", + ); + + return; + } + + const deleted = await withHttpErrors( + "delete", + async () => await model.service(c).delete(targetId as never), + ); + if (!deleted) return; + await emitContentEvent( + c, + definition, + "deleted", + { contentId: deleted.id }, + { + pluginId, + publicState: contentPublicStateOf(definition, deleted, null), + }, + ); + await syncContentSearch(c, definition, { + operation: "delete", + pluginId, + row: deleted, + }); + }; + + if (await hasReplies(id)) { + await c + .get("db") + .update(table) + .set({ deletedAt: new Date(), deletedBy: user.id }) + .where(eq(columns.id, id as never)); + await emitContentEvent( + c, + definition, + "deleted", + { contentId: id }, + { + pluginId, + publicState: contentPublicStateOf(definition, row, null), + }, + ); + await syncContentSearch(c, definition, { + operation: "delete", + pluginId, + row, + }); + + return c.json({ id }, 200, { "Cache-Control": "private, no-store" }); + } + + await removeRow(row); + + let parentId = parentColumn ? row[parentField] : null; + while (parentId !== null && parentId !== undefined) { + const parent = await readRow(c, model, parentId as ContentId); + if ( + !parent || + parent.deletedAt === null || + parent.deletedAt === undefined || + (await hasReplies(parentId)) + ) { + break; + } + await removeRow(parent); + parentId = parent[parentField]; + } + + return c.json({ id }, 200, { "Cache-Control": "private, no-store" }); + }, + }); +}; diff --git a/packages/vitnode/src/content/server/public-write-authorization.ts b/packages/vitnode/src/content/server/public-write-authorization.ts index 8989de77ef..2c75088612 100644 --- a/packages/vitnode/src/content/server/public-write-authorization.ts +++ b/packages/vitnode/src/content/server/public-write-authorization.ts @@ -5,6 +5,8 @@ import type { ContentRecordContainer } from "../containers/server/policy"; import type { ContentActor } from "../revisions"; import type { AnyContentTypeDefinition } from "../types"; +import { resolveOwnContentPolicy } from "../../api/lib/own-content-policy"; +import { ownContentEditOpen } from "../../lib/own-content"; import { CONTENT_PERMISSIONS } from "../const"; import { contentAuthorMayEdit } from "../containers/policies"; import { canModerateContent } from "../containers/server/policy"; @@ -50,8 +52,10 @@ export type ContentPublicWriteAuthorization = /** * Who may edit a record from the public site. A moderator holding `can_edit` * for the record's container edits anything there, at any age. An owner edits - * their own record while it is not hidden and the container's `authorEdit` - * policy still allows it - measured from the row's server `createdAt`. + * their own record while it is not hidden, one of their roles has "Edit own + * content" on with a window that is still open, and the container's + * `authorEdit` policy still allows it - both windows measured from the row's + * server `createdAt`. */ export const authorizeContentPublicWrite = async ( c: Context, @@ -79,12 +83,23 @@ export const authorizeContentPublicWrite = async ( } const hidden = row.hiddenAt !== null && row.hiddenAt !== undefined; + const deleted = row.deletedAt !== null && row.deletedAt !== undefined; const owns = update.own && !hidden && + !deleted && contentPublicWriteOwnerOf(definition, row) === actor.userId; if (!owns) return { allowed: false, reason: null, via: null }; + const user = c.get("user"); + const rolePolicy = user + ? (await resolveOwnContentPolicy(c, user)).edit + : { allowed: false, minutes: null }; + if (!rolePolicy.allowed) return { allowed: false, reason: null, via: null }; + if (!ownContentEditOpen(rolePolicy, row.createdAt as Date | undefined)) { + return { allowed: false, reason: "editWindowClosed", via: null }; + } + if ( policy && !contentAuthorMayEdit(policy.policy, row.createdAt as Date | undefined) diff --git a/packages/vitnode/src/content/server/public-write-routes.test.ts b/packages/vitnode/src/content/server/public-write-routes.test.ts index a5e9ba9766..e9bdc99039 100644 --- a/packages/vitnode/src/content/server/public-write-routes.test.ts +++ b/packages/vitnode/src/content/server/public-write-routes.test.ts @@ -764,7 +764,10 @@ describe("generated surface", () => { "/": ["get", "post"], "/permissions": ["get"], "/{id}": ["patch"], + "/{id}/delete": ["post"], "/{id}/edit": ["get"], + "/{id}/hide": ["post"], + "/{id}/unhide": ["post"], "/{slug}": ["get"], }); }); @@ -772,6 +775,8 @@ describe("generated surface", () => { it("generates no edit routes when only create is enabled", () => { expect(operations(harness(drafts as never, []).app)).toEqual({ "/": ["get", "post"], + "/permissions": ["get"], + "/{id}/delete": ["post"], "/{slug}": ["get"], }); }); diff --git a/packages/vitnode/src/content/server/public-write-service.ts b/packages/vitnode/src/content/server/public-write-service.ts index 6db4be9f36..8c3e7d8317 100644 --- a/packages/vitnode/src/content/server/public-write-service.ts +++ b/packages/vitnode/src/content/server/public-write-service.ts @@ -53,6 +53,7 @@ import { } from "../errors"; import { contentIdKey, requireContentId } from "../ids"; import { isContentReferenceCollection } from "../paths"; +import { canViewHiddenContentRecord } from "./hidden-access"; import { authorizeContentPublicWrite, contentPublicWriteRefusal, @@ -281,6 +282,28 @@ export const createContentPublicWriteService = < .filter(row => isContentBaseRowPublic(row)) .map(row => contentIdKey(row.id as ContentId)), ); + const rootTarget = + field === definition.thread.rootField + ? definition.fields[field] + : undefined; + if (rootTarget?.kind === "relation" && !rootTarget.self) { + const contentTypeId = rootTarget.target().id; + const hiddenIds = rows + .filter( + row => + !reachable.has(contentIdKey(row.id as ContentId)) && + isContentBaseRowPublic({ ...row, hiddenAt: null }), + ) + .map(row => row.id as ContentId); + const viewable = await Promise.all( + hiddenIds.map(async itemId => + canViewHiddenContentRecord(c, { contentTypeId, itemId }), + ), + ); + hiddenIds.forEach((id, index) => { + if (viewable[index]) reachable.add(contentIdKey(id)); + }); + } if (ids.every(id => reachable.has(contentIdKey(id)))) continue; throw new ContentPublicWriteInputError({ diff --git a/packages/vitnode/src/content/server/publication.ts b/packages/vitnode/src/content/server/publication.ts index 96cd8ce701..f38c3df745 100644 --- a/packages/vitnode/src/content/server/publication.ts +++ b/packages/vitnode/src/content/server/publication.ts @@ -13,6 +13,7 @@ import { ContentEngineError } from "../errors"; * the content type has `visibility`. */ export interface PublicationColumns { + deletedAt?: PgColumn; hiddenAt?: PgColumn; publishedAt: PgColumn; status: PgColumn; @@ -38,7 +39,8 @@ export const publicationColumns = ( definition: AnyContentTypeDefinition, columns: Record, ): PublicationColumns => { - const { hiddenAt, publishedAt, status } = columns; + const { deletedAt, hiddenAt, publishedAt, status } = columns; + const thread = definition.thread.enabled && deletedAt ? { deletedAt } : {}; if (!definition.publication.enabled || !publishedAt || !status) { throw new ContentEngineError( @@ -47,7 +49,7 @@ export const publicationColumns = ( ); } - if (!definition.visibility.enabled) return { publishedAt, status }; + if (!definition.visibility.enabled) return { ...thread, publishedAt, status }; if (!hiddenAt) { throw new ContentEngineError( @@ -56,7 +58,7 @@ export const publicationColumns = ( ); } - return { hiddenAt, publishedAt, status }; + return { ...thread, hiddenAt, publishedAt, status }; }; /** @@ -72,6 +74,7 @@ export const publishedCondition = ( isNotNull(columns.publishedAt), lte(columns.publishedAt, sql`now()`), columns.hiddenAt === undefined ? undefined : isNull(columns.hiddenAt), + columns.deletedAt === undefined ? undefined : isNull(columns.deletedAt), ); const hiddenPublishedCondition = ( diff --git a/packages/vitnode/src/content/server/report-target.ts b/packages/vitnode/src/content/server/report-target.ts index 9bd45161c7..23064ad96c 100644 --- a/packages/vitnode/src/content/server/report-target.ts +++ b/packages/vitnode/src/content/server/report-target.ts @@ -28,6 +28,7 @@ import { contentDeliveryPublicUrl } from "../delivery"; import { contentIdKey, parseContentId } from "../ids"; import { contentPublicHref } from "../public-url"; import { isRichTextDocument } from "../rich-text/document"; +import { richTextToPlainText } from "../rich-text/plain-text"; import { contentSearchUrl } from "../search"; import { isContentHidden } from "../visibility"; import { resolveContentActor } from "./actor"; @@ -159,6 +160,20 @@ const snapshotValue = async ( } }; +const REPORT_TITLE_EXCERPT_LENGTH = 120; + +const reportTitleOf = (value: unknown): null | string => { + if (typeof value === "string") return value; + if (!isRichTextDocument(value)) return null; + + const text = richTextToPlainText(value).replace(/\s+/g, " ").trim(); + if (text === "") return null; + + return text.length > REPORT_TITLE_EXCERPT_LENGTH + ? `${text.slice(0, REPORT_TITLE_EXCERPT_LENGTH - 1).trimEnd()}…` + : text; +}; + const contentUrlOf = ( c: Context, definition: AnyContentTypeDefinition, @@ -318,7 +333,7 @@ const captureContent = async ( } const titleField = definition.reports.titleField; - const title = titleField ? valueOf(titleField) : null; + const title = titleField ? reportTitleOf(valueOf(titleField)) : null; const slug = definition.publicApi.enabled ? (visible[definition.publicApi.slugField] ?? null) : null; @@ -350,7 +365,7 @@ const captureContent = async ( updatedAt: latestIso(base.updatedAt, translation?.updatedAt), version: typeof base.version === "number" ? base.version : null, }, - title: typeof title === "string" ? title : null, + title, url: contentUrlOf(c, definition, { locale: servedLocale, slug }), }; }, @@ -395,7 +410,7 @@ const currentContent = async ( }; }; -const visibilityAction = ( +export const visibilityAction = ( model: AnyContentModel, pluginId: string, action: ContentVisibilityAction, diff --git a/packages/vitnode/src/content/server/search-document.ts b/packages/vitnode/src/content/server/search-document.ts index ce7b282ecf..a253ee7514 100644 --- a/packages/vitnode/src/content/server/search-document.ts +++ b/packages/vitnode/src/content/server/search-document.ts @@ -201,8 +201,20 @@ export const contentSearchDocument = ( ...(context.containerId === null ? {} : { containerId: context.containerId }), + } + : {}), + ...(context || definition.thread.enabled + ? { metadata: { - context: { title: normalize(context.title), url: context.url }, + ...(context + ? { + context: { + title: normalize(context.title), + url: context.url, + }, + } + : {}), + ...(definition.thread.enabled ? { thread: true } : {}), }, } : {}), diff --git a/packages/vitnode/src/content/server/search-related.ts b/packages/vitnode/src/content/server/search-related.ts index bca5fa41a1..973dfa0896 100644 --- a/packages/vitnode/src/content/server/search-related.ts +++ b/packages/vitnode/src/content/server/search-related.ts @@ -1,4 +1,4 @@ -import type { PgColumn } from "drizzle-orm/pg-core"; +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; import type { Context } from "hono"; import { and, eq, inArray } from "drizzle-orm"; @@ -15,6 +15,7 @@ import { contentSearchTemplateUrl, contentSearchUrl, } from "../search"; +import { listContentLanguages } from "./language-resolver"; import { contentLocaleRouting } from "./locale-routing"; import { findContentModel } from "./model"; import { isContentRowPublic } from "./search-document"; @@ -53,6 +54,7 @@ export const toRelatedSearchContext = ( target: AnyContentTypeDefinition, row: object, routing: ContentLocaleRouting, + locale?: string, ): ContentSearchContext => { const values = row as Record; const title = values[target.search.titleField]; @@ -68,7 +70,7 @@ export const toRelatedSearchContext = ( title: typeof title === "string" ? title : "", url: typeof slug === "string" - ? contentSearchUrl({ definition: target, routing, slug }) + ? contentSearchUrl({ definition: target, locale, routing, slug }) : null, }; }; @@ -89,13 +91,76 @@ export const toContainerSearchContext = ( }), }); +interface RelatedTranslation { + locale: string; + values: Record; +} + +const loadRelatedTranslations = async ( + c: Context, + { + columns, + defaultLocale, + ids, + names, + table, + }: { + columns: Record; + defaultLocale: string; + ids: readonly (number | string)[]; + names: string[]; + table: PgTable; + }, +): Promise> => { + const itemIdColumn = columns.itemId; + const languageIdColumn = columns.languageId; + if (!itemIdColumn || !languageIdColumn || names.length === 0) { + return new Map(); + } + + const selection: Record = { + itemId: itemIdColumn, + languageId: languageIdColumn, + }; + for (const name of names) { + const column = columns[name]; + if (column) selection[name] = column; + } + + const [rows, languages] = await Promise.all([ + c + .get("db") + .select(selection) + .from(table) + .where(inArray(itemIdColumn, [...ids])), + listContentLanguages(c), + ]); + const localeOf = new Map( + languages.map(language => [language.id, language.locale]), + ); + + const picked = new Map(); + for (const row of rows) { + const locale = localeOf.get(row.languageId as number); + if (locale === undefined) continue; + + const key = contentIdKey(row.itemId as number | string); + const current = picked.get(key); + if (current && current.locale === defaultLocale) continue; + if (!current || locale === defaultLocale) { + picked.set(key, { locale, values: row }); + } + } + + return picked; +}; + const loadRelatedContexts = async ( c: Context, - source: Extract, + target: AnyContentTypeDefinition, ids: readonly (number | string)[], ): Promise<[string, ContentSearchContext][]> => { - const { target } = source; - if (!target.search.enabled) return []; + if (!target.search.enabled || ids.length === 0) return []; const targetModel = findContentModel( c.get("core")?.contentModels ?? [], @@ -104,37 +169,72 @@ const loadRelatedContexts = async ( if (!targetModel) return []; const columns = targetModel.columns as Record; + const translationColumns = targetModel.translationColumns as null | Record< + string, + PgColumn | undefined + >; + const { titleField } = target.search; + const { slugField } = target.publicApi; const idColumn = columns.id; - const titleColumn = columns[target.search.titleField]; - const slugColumn = columns[target.publicApi.slugField]; - if (!idColumn || !titleColumn) return []; + const translated = [titleField, slugField].filter( + name => !columns[name] && translationColumns?.[name], + ); + if (!idColumn || (!columns[titleField] && !translated.includes(titleField))) { + return []; + } - const selection: Record = { - id: idColumn, - [target.search.titleField]: titleColumn, - ...(slugColumn ? { [target.publicApi.slugField]: slugColumn } : {}), - }; - for (const name of RELATED_PUBLICATION_COLUMNS) { + const selection: Record = { id: idColumn }; + for (const name of [titleField, slugField, ...RELATED_PUBLICATION_COLUMNS]) { const column = columns[name]; if (column) selection[name] = column; } - const rows = await c - .get("db") - .select(selection) - .from(targetModel.table) - .where(inArray(idColumn, [...ids])); + const [rows, translations] = await Promise.all([ + c + .get("db") + .select(selection) + .from(targetModel.table) + .where(inArray(idColumn, [...ids])), + translationColumns && targetModel.translationTable + ? loadRelatedTranslations(c, { + columns: translationColumns, + defaultLocale: target.localization.defaultLocale, + ids, + names: translated, + table: targetModel.translationTable, + }) + : new Map(), + ]); const routing = contentLocaleRouting(c); return rows.flatMap(row => { const id = parseContentId(target.idStrategy, row.id); + if (id === null) return []; + + const key = contentIdKey(id); + const translation = translations.get(key); - return id === null - ? [] - : [[contentIdKey(id), toRelatedSearchContext(target, row, routing)]]; + return [ + [ + key, + toRelatedSearchContext( + target, + translation ? { ...row, ...translation.values } : row, + routing, + translation?.locale, + ), + ], + ]; }); }; +export const loadRelatedSearchContext = async ( + c: Context, + target: AnyContentTypeDefinition, + id: number | string, +): Promise => + new Map(await loadRelatedContexts(c, target, [id])).get(contentIdKey(id)); + const loadContainerContexts = async ( c: Context, source: Extract, @@ -185,7 +285,7 @@ export const loadContentSearchContexts = async ( return new Map( source.kind === "relation" - ? await loadRelatedContexts(c, source, [...ids.values()]) + ? await loadRelatedContexts(c, source.target, [...ids.values()]) : await loadContainerContexts(c, source, [...ids.values()]), ); }; diff --git a/packages/vitnode/src/content/server/search-sync.ts b/packages/vitnode/src/content/server/search-sync.ts index bf2400afbc..d7be892b7e 100644 --- a/packages/vitnode/src/content/server/search-sync.ts +++ b/packages/vitnode/src/content/server/search-sync.ts @@ -31,6 +31,7 @@ import { createContentSearchIndexer } from "./search-indexer"; import { contentSearchContextKey, loadContentSearchContexts, + loadRelatedSearchContext, toContainerSearchContext, toRelatedSearchContext, } from "./search-related"; @@ -342,7 +343,9 @@ export const runContentSearchDependentsTask = async ( await reindexContentSearchDependent(c, dependent, { after, - context: toRelatedSearchContext(definition, row, contentLocaleRouting(c)), + context: + (await loadRelatedSearchContext(c, definition, itemId)) ?? + toRelatedSearchContext(definition, row, contentLocaleRouting(c)), field: payload.field, value: itemId, }); @@ -375,6 +378,16 @@ const syncContentSearchDependents = async ( input, itemId, run: async () => { + const context = + sync === "delete" + ? undefined + : ((await loadRelatedSearchContext(c, definition, itemId)) ?? + toRelatedSearchContext( + definition, + input.row, + contentLocaleRouting(c), + )); + for (const { entry, field } of dependents) { const dependent = contentDefinitionOf(entry.model); @@ -388,6 +401,7 @@ const syncContentSearchDependents = async ( } continue; } + if (!context) continue; // Absent outside a full request - a test, a script - which keeps the // whole walk inline. @@ -397,11 +411,7 @@ const syncContentSearchDependents = async ( // comments is consistent the moment the edit returns. Thousands of them // are the queue's job, not the editor's request. await reindexContentSearchDependent(c, entry, { - context: toRelatedSearchContext( - definition, - input.row, - contentLocaleRouting(c), - ), + context, ...(queue ? { defer: async after => { diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index a3574b9254..5e799f70d5 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -31,6 +31,7 @@ import { core_users } from "../../database/users"; import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS, + CONTENT_THREAD_FIELDS, CONTENT_VISIBILITY_FIELDS, } from "../const"; import { ContentEngineError } from "../errors"; @@ -42,6 +43,7 @@ import { buildEditorialColumns, buildPublicationColumns, buildSystemColumns, + buildThreadColumns, buildVisibilityColumns, } from "./column-builders"; @@ -268,6 +270,9 @@ export const createContentTable = < ...(definition.visibility.enabled ? buildVisibilityColumns(() => core_users.id) : {}), + ...(definition.thread.enabled + ? buildThreadColumns(() => core_users.id) + : {}), }; for (const name of Object.keys(fields)) { @@ -362,7 +367,10 @@ export const contentTableColumns = < "updatedAt", ...(definition.publication.enabled ? CONTENT_PUBLICATION_FIELDS : []), ...(definition.editorial.enabled ? CONTENT_EDITORIAL_FIELDS : []), - ...(definition.visibility.enabled ? CONTENT_VISIBILITY_FIELDS : []), + ...(definition.visibility.enabled + ? [...CONTENT_VISIBILITY_FIELDS, "hiddenReason"] + : []), + ...(definition.thread.enabled ? CONTENT_THREAD_FIELDS : []), ...Object.keys(contentStorageColumns(sharedFields)), ]; diff --git a/packages/vitnode/src/content/server/thread.ts b/packages/vitnode/src/content/server/thread.ts index 515d5aa549..60a9a72f46 100644 --- a/packages/vitnode/src/content/server/thread.ts +++ b/packages/vitnode/src/content/server/thread.ts @@ -9,10 +9,12 @@ import { and, asc, count, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; +import type { ContentPublicHiddenAccess } from "./hidden-list"; import type { ContentModel } from "./model"; import type { ContentDatabase } from "./service"; import { ContentInputError } from "../errors"; +import { isContentHidden } from "../visibility"; import { publicationColumns, publishedCondition } from "./publication"; import { isContentBaseRowPublic } from "./visibility"; @@ -148,8 +150,22 @@ export const assertContentThreadPlacement = async ( }; export interface ContentThreadNode { + /** + * Set for a reply that was deleted, or hidden from this reader, while it + * still had answers: it stays in the thread as an anonymous placeholder - no + * text, no author - dated when it was written. `byAuthor` tells whether its + * own author deleted or hid it, rather than a moderator. + */ + deleted: null | { byAuthor: boolean; createdAt: Date }; /** How deep the reply sits: 1 for a direct answer to the root. */ depth: number; + /** + * `true` for a hidden reply shown only because the reader may see it - a + * moderator, an editor or its author. Never set without `hiddenAccess`. + */ + hidden: boolean; + /** Why it was hidden, when the moderator gave a reason. Only with `hidden`. */ + hiddenReason: null | string; id: ContentId; parentId: ContentId | null; /** The public record, or `null` for a removed reply kept so its answers stay in place. */ @@ -232,13 +248,25 @@ export const readContentThread = async < { first, after, + hiddenAccess, project, root, }: { after?: string; first: number; - /** Public rows for these ids, in any order. */ - project: (ids: ContentId[]) => Promise>; + /** + * Hidden replies this reader may see, from `contentThreadHiddenAccess`. + * They are kept with `hidden: true`; omit it for the public thread. + */ + hiddenAccess?: ContentPublicHiddenAccess; + /** + * Rows for these ids, in any order: public ones, plus the hidden ones in + * `hiddenIds` the reader may see. + */ + project: ( + ids: ContentId[], + options: { hiddenIds: ContentId[] }, + ) => Promise>; root: ContentId; }, ): Promise> => { @@ -252,6 +280,10 @@ export const readContentThread = async < const published = publicationColumns(definition, columns); const publicCondition = publishedCondition(published); + const ownerField = + definition.search.authorField ?? definition.publicWrite.ownerField; + const ownerColumn = ownerField ? columns[ownerField] : undefined; + const nodeSelection = { createdAt: columns.createdAt, createdAtText: sql`${columns.createdAt}::text`, @@ -259,9 +291,26 @@ export const readContentThread = async < parent: parentColumn, publishedAt: columns.publishedAt, status: columns.status, - ...(columns.hiddenAt ? { hiddenAt: columns.hiddenAt } : {}), + ...(columns.hiddenAt + ? { hiddenAt: columns.hiddenAt, hiddenBy: columns.hiddenBy } + : {}), + ...(columns.hiddenReason ? { hiddenReason: columns.hiddenReason } : {}), + ...(columns.deletedAt + ? { deletedAt: columns.deletedAt, deletedBy: columns.deletedBy } + : {}), + ...(ownerColumn ? { owner: ownerColumn } : {}), }; + const isViewableHidden = (row: NodeRow): boolean => + hiddenAccess !== undefined && + isContentHidden(row) && + isContentBaseRowPublic({ ...row, hiddenAt: null }) && + (hiddenAccess.all || + (hiddenAccess.authorId !== null && row.owner === hiddenAccess.authorId)); + + const isReadable = (row: NodeRow): boolean => + isContentBaseRowPublic(row) || isViewableHidden(row); + const topLevel = and(eq(rootColumn, root), isNull(parentColumn)); const after_ = (position: null | ThreadPosition) => { if (!position) return undefined; @@ -294,9 +343,7 @@ export const readContentThread = async < .limit(first + 1)) as NodeRow[]; if (batch.length === 0) break; - const hidden = batch - .filter(row => !isContentBaseRowPublic(row)) - .map(row => row.id); + const hidden = batch.filter(row => !isReadable(row)).map(row => row.id); const answered = new Set( hidden.length === 0 ? [] @@ -308,9 +355,7 @@ export const readContentThread = async < ).map(row => String(row.parent)), ); collected.push( - ...batch.filter( - row => isContentBaseRowPublic(row) || answered.has(String(row.id)), - ), + ...batch.filter(row => isReadable(row) || answered.has(String(row.id))), ); const last = batch.at(-1); @@ -372,7 +417,7 @@ export const readContentThread = async < for (const child of children.get(String(row.id)) ?? []) { if (keep(child)) any = true; } - if (any || isContentBaseRowPublic(row)) { + if (any || isReadable(row)) { kept.add(String(row.id)); return true; @@ -382,21 +427,52 @@ export const readContentThread = async < }; for (const row of page) keep(row); + const readable = [...kept] + .map(key => nodes.get(key)) + .filter((row): row is NodeRow => row !== undefined) + .filter(row => isReadable(row)); const records = await project( - [...kept] - .map(key => nodes.get(key)) - .filter((row): row is NodeRow => row !== undefined) - .filter(row => isContentBaseRowPublic(row)) - .map(row => row.id), + readable.map(row => row.id), + { + hiddenIds: readable + .filter(row => !isContentBaseRowPublic(row)) + .map(row => row.id), + }, ); + const placeholderOf = ( + row: NodeRow, + owner: null | number, + ): ContentThreadNode["deleted"] => { + const removedBy = + row.deletedAt instanceof Date + ? row.deletedBy + : row.hiddenAt instanceof Date + ? row.hiddenBy + : undefined; + if (removedBy === undefined) return null; + + return { + byAuthor: owner !== null && removedBy === owner, + createdAt: row.createdAt, + }; + }; + const items: ContentThreadNode[] = []; const walk = (row: NodeRow, depth: number): void => { const key = String(row.id); if (!kept.has(key)) return; const record = records.get(key) ?? null; + const owner = typeof row.owner === "number" ? row.owner : null; + const hidden = record !== null && isViewableHidden(row); items.push({ + deleted: record === null ? placeholderOf(row, owner) : null, depth, + hidden, + hiddenReason: + hidden && typeof row.hiddenReason === "string" + ? row.hiddenReason + : null, id: row.id, parentId: row.parent, record, diff --git a/packages/vitnode/src/content/server/visibility.ts b/packages/vitnode/src/content/server/visibility.ts index 68f67fa9d2..c4ea892a9a 100644 --- a/packages/vitnode/src/content/server/visibility.ts +++ b/packages/vitnode/src/content/server/visibility.ts @@ -85,8 +85,12 @@ export const visibilityValues = ( actorUserId: null | number | undefined, ): Record => action === "hide" - ? { hiddenAt: sql`now()`, hiddenBy: actorUserId ?? null } - : { hiddenAt: null, hiddenBy: null }; + ? { + hiddenAt: sql`now()`, + hiddenBy: actorUserId ?? null, + hiddenReason: null, + } + : { hiddenAt: null, hiddenBy: null, hiddenReason: null }; const toDate = (value: unknown): Date | null => { if (value instanceof Date) return value; @@ -105,6 +109,7 @@ export const visibilityStateOf = ( }); export const isContentBaseRowPublic = (row: Record): boolean => + (row.deletedAt === null || row.deletedAt === undefined) && isContentPubliclyVisible({ hiddenAt: row.hiddenAt as Date | null | string | undefined, publishedAt: row.publishedAt as Date | null | string | undefined, diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index b32122161e..42bdd3c9f6 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1019,6 +1019,12 @@ type ContentVisibilityColumns = TDefinition extends { /** Options for `reports`. Every field named here must be a field of the content type. */ export interface ContentReportsConfig { + /** + * Let authors report their own records too - for a comment, a way to ask the + * moderators to look at something they wrote. Off by default: the Report + * button skips the author's own records. + */ + selfReport?: boolean; /** * Fields copied into the report when it is filed, so moderators see what was * reported even after an edit. Self-contained kinds only (text, textarea, @@ -1027,7 +1033,9 @@ export interface ContentReportsConfig { */ snapshotFields?: TField[]; /** - * A text, textarea or slug field used as the report's title. Defaults to + * A text, textarea, slug or rich text field used as the report's title. A + * rich text field gives its first 120 characters as plain text - the way to + * title a comment, which has no title of its own. Defaults to * `admin.titleField` when it is public, otherwise none. */ titleField?: TField; @@ -1058,6 +1066,7 @@ export interface ResolvedContentReportsConfig< TEnabled extends boolean = boolean, > { enabled: TEnabled; + selfReport: boolean; snapshotFields: string[]; titleField: null | string; } diff --git a/packages/vitnode/src/database/roles.ts b/packages/vitnode/src/database/roles.ts index 1d5fbf016e..be4e73fc65 100644 --- a/packages/vitnode/src/database/roles.ts +++ b/packages/vitnode/src/database/roles.ts @@ -23,6 +23,10 @@ export const core_roles = camelCase.table.withRLS( allowUploadCover: t.boolean().notNull().default(true), maxCoverSize: t.integer().notNull().default(5120), allowEditPersonalInfo: t.boolean().notNull().default(true), + allowEditOwnContent: t.boolean().notNull().default(true), + editOwnContentMinutes: t.integer(), + allowDeleteOwnContent: t.boolean().notNull().default(false), + allowHideOwnContent: t.boolean().notNull().default(false), }), t => [index("core_roles_updated_at_id_idx").on(t.updatedAt, t.id)], ); diff --git a/packages/vitnode/src/hooks/use-reveal-hash-target.ts b/packages/vitnode/src/hooks/use-reveal-hash-target.ts index bb30452ab9..1a85e0869d 100644 --- a/packages/vitnode/src/hooks/use-reveal-hash-target.ts +++ b/packages/vitnode/src/hooks/use-reveal-hash-target.ts @@ -38,7 +38,14 @@ const keepInView = (element: HTMLElement): (() => void) => { return stop; }; -const readHash = () => decodeURIComponent(window.location.hash.slice(1)); +const readHash = () => { + const hash = window.location.hash.slice(1); + try { + return decodeURIComponent(hash); + } catch { + return hash; + } +}; /** * Brings the element named by the URL hash into view in a paginated list - a diff --git a/packages/vitnode/src/lib/own-content.ts b/packages/vitnode/src/lib/own-content.ts new file mode 100644 index 0000000000..85047c2646 --- /dev/null +++ b/packages/vitnode/src/lib/own-content.ts @@ -0,0 +1,74 @@ +/** The longest edit window a role can set, in minutes: one year. */ +export const ROLE_EDIT_OWN_CONTENT_MAX_MINUTES = 60 * 24 * 365; + +export interface RoleOwnContentLimits { + allowDeleteOwnContent: boolean; + allowEditOwnContent: boolean; + allowHideOwnContent: boolean; + /** `null` is unlimited. */ + editOwnContentMinutes: null | number; +} + +export interface OwnContentEditPolicy { + allowed: boolean; + /** How long after creation an author may edit, or `null` for no limit. */ + minutes: null | number; +} + +/** What a member may do with content they wrote. */ +export interface OwnContentPolicy { + delete: boolean; + edit: OwnContentEditPolicy; + hide: boolean; +} + +const DEFAULT_OWN_CONTENT_POLICY: OwnContentPolicy = { + delete: false, + edit: { allowed: true, minutes: null }, + hide: false, +}; + +/** + * What a member's roles allow together: the most generous one wins. Each action + * is allowed when any role allows it, and the longest edit window of those + * roles applies - an unlimited one beats any number of minutes. No roles at all + * reads as the column defaults: editing allowed without a limit, deleting and + * hiding not allowed. + */ +export const effectiveOwnContentPolicy = ( + roles: readonly RoleOwnContentLimits[], +): OwnContentPolicy => { + if (roles.length === 0) return DEFAULT_OWN_CONTENT_POLICY; + + const editing = roles.filter(role => role.allowEditOwnContent); + const edit: OwnContentEditPolicy = + editing.length === 0 + ? { allowed: false, minutes: null } + : editing.some(role => role.editOwnContentMinutes === null) + ? { allowed: true, minutes: null } + : { + allowed: true, + minutes: Math.max( + ...editing.map(role => role.editOwnContentMinutes ?? 0), + ), + }; + + return { + delete: roles.some(role => role.allowDeleteOwnContent), + edit, + hide: roles.some(role => role.allowHideOwnContent), + }; +}; + +/** Whether `policy` still lets the author edit a record created at `createdAt`. */ +export const ownContentEditOpen = ( + policy: OwnContentEditPolicy, + createdAt: Date | null | undefined, + now: Date = new Date(), +): boolean => { + if (!policy.allowed) return false; + if (policy.minutes === null) return true; + if (!(createdAt instanceof Date)) return false; + + return now.getTime() - createdAt.getTime() <= policy.minutes * 60_000; +}; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 3f8aef77bf..0faad31d27 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -118,6 +118,7 @@ }, "types": { "blog_post": "Article", + "comment": "Comment", "forum_comment": "Comment", "forum_topic": "Topic", "unknown": "Content" @@ -229,6 +230,15 @@ "increase": "Increase", "copied": "Copied", "copy_failed": "Couldn't copy to the clipboard. Try selecting the text instead.", + "share": { + "title": "Share", + "link": "Link to share", + "copy": "Copy link", + "on": "Share on {network}", + "email": "Send by email", + "more": "More options…", + "failed": "Couldn't open your device's share menu." + }, "more_count": "+{count} more", "previous": "Previous", "next": "Next", @@ -1550,6 +1560,113 @@ } } }, + "comments": { + "title": "{count, plural, =0 {Comments} one {# comment} other {# comments}}", + "loading": "Loading comments", + "empty": { + "title": "No comments yet", + "desc": "Got a thought on this? Start the conversation." + }, + "load_failed": { + "title": "Could not load comments", + "desc": "Please check your connection and try again.", + "retry": "Try again" + }, + "load_more": "Load more comments", + "truncated": "This thread is very long, so some deeper comments are not shown here.", + "anchor_missing": "The comment this link points to is no longer available.", + "compose": { + "title": "Leave a comment", + "guest": "You need an account to comment.", + "sign_in": "Sign in to comment", + "open": "Write a comment…" + }, + "form": { + "body_label": "Your comment", + "edit_label": "Edit your comment", + "answer_label": "Reply to {name}", + "submit": "Post comment", + "save": "Save", + "cancel": "Cancel", + "required": "Write a comment first.", + "proposed": "You are editing your change that still waits for a moderator." + }, + "outcome": { + "published": { + "title": "Comment posted", + "desc": "Your comment is live in the conversation." + }, + "draft": { + "title": "Comment saved as a draft", + "desc": "It is not public yet, so nobody else can see it." + }, + "pending": { + "title": "Your comment is waiting for a moderator", + "desc": "It appears in the conversation once it is approved." + }, + "edited": { + "title": "Comment saved", + "desc": "Your changes are live." + }, + "edit_pending": { + "title": "Your edit is waiting for a moderator", + "desc": "The comment keeps its current text until the change is approved." + } + }, + "errors": { + "title": "That did not go through", + "unknown": "Something went wrong. Please try again in a moment.", + "forbidden": "You are not allowed to do that here.", + "not_found": "This no longer exists, or you cannot see it.", + "unauthenticated": "Your session ended. Sign in and try again.", + "busy": "Another action on this conversation is still running. Wait a moment and try again." + }, + "permalink": "Link to this comment", + "edited_label": "edited", + "reply": "Reply", + "edit": "Edit", + "report": "Report", + "report_type": "comment", + "more_actions": "More actions for the comment by {name}", + "someone": "this comment", + "deleted_user": "Deleted user", + "removed": "This comment was removed.", + "show_replies": "{count, plural, one {Show # reply} other {Show # replies}}", + "hide_replies": "{count, plural, one {Hide # reply} other {Hide # replies}}", + "visibility": { + "hidden_label": "Hidden", + "hidden_reason": "Reason: {reason}", + "hide": { + "action": "Hide", + "title": "Comment hidden", + "desc": "Only moderators and its author can see it now.", + "prompt_title": "Hide this comment?", + "prompt_desc": "Everyone except moderators and its author stops seeing it.", + "reason": "Reason", + "reason_desc": "Optional. Moderators and the author will see it." + }, + "unhide": { + "action": "Unhide", + "title": "Comment visible again", + "desc": "Everyone can read it in the conversation." + } + }, + "delete": { + "action": "Delete", + "title": "Delete this comment?", + "desc": "It is removed for everyone and cannot be restored. Replies to it stay in the conversation.", + "confirm": "Delete comment", + "done": { + "title": "Comment deleted", + "desc": "It is gone from the conversation." + } + }, + "placeholder": { + "name": "[deleted]", + "by_author": "Comment deleted by user", + "by_moderator": "Comment removed by a moderator" + } + }, "content": { "empty": { "title": "Nothing here yet", @@ -3320,6 +3437,20 @@ "or": "or", "unlimited": "Unlimited" }, + "edit_own_content": { + "allow": "Allow editing own content", + "minutes": "Time to edit", + "minutes_desc": "How long after posting a member can still edit it.", + "in_unit": "in minutes", + "or": "or", + "unlimited": "Unlimited" + }, + "delete_own_content": { + "allow": "Allow deleting own content" + }, + "hide_own_content": { + "allow": "Allow hiding own content" + }, "personal_info": { "allow_edit": "Allow editing personal information", "allow_edit_desc": "Members of this role can set their first name, last name, headline, and choose which name their profile shows." diff --git a/packages/vitnode/src/tanstack/admin/roles/query.ts b/packages/vitnode/src/tanstack/admin/roles/query.ts index a7805e082a..f95f2c8c70 100644 --- a/packages/vitnode/src/tanstack/admin/roles/query.ts +++ b/packages/vitnode/src/tanstack/admin/roles/query.ts @@ -77,11 +77,15 @@ export const useAdminRoleMutations = (): { }, onSave: async ({ id, values }) => { const body = { + allowDeleteOwnContent: values.allowDeleteOwnContent, + allowEditOwnContent: values.allowEditOwnContent, + allowHideOwnContent: values.allowHideOwnContent, allowEditPersonalInfo: values.allowEditPersonalInfo, allowUploadAvatar: values.allowUploadAvatar, allowUploadCover: values.allowUploadCover, allowUploadFiles: values.allowUploadFiles, color: values.color, + editOwnContentMinutes: values.editOwnContentMinutes, maxAvatarSize: values.maxAvatarSize, maxCoverSize: values.maxCoverSize, maxStorageForSubmit: values.maxStorageForSubmit, diff --git a/packages/vitnode/src/tanstack/comments/composer-form.tsx b/packages/vitnode/src/tanstack/comments/composer-form.tsx new file mode 100644 index 0000000000..ea6f7f6fa6 --- /dev/null +++ b/packages/vitnode/src/tanstack/comments/composer-form.tsx @@ -0,0 +1,168 @@ +import { CheckIcon, SendHorizontalIcon } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { ContentId } from "@/content/ids"; + +import { + AutoForm, + type AutoFormOnSubmit, + AutoFormSubmitButton, +} from "@/components/form/auto-form"; +import { AutoFormEditor } from "@/components/form/fields/editor"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + emptyRichTextDocument, + isRichTextEmpty, + type RichTextDocument, + zodRichTextDocument, +} from "@/content/rich-text"; + +import type { ContentCommentWriteOutcome } from "./thread"; +import type { ContentCommentEditable } from "./use-content-comments"; + +import { useContentPublicWrite } from "../content/public-write"; +import { useContentCommentsContext } from "./root"; + +const FORM_FIELDS = ["body"] as const; + +export type ContentCommentComposerFormProps = { + /** Puts the cursor in the editor when it opens. */ + autoFocus?: boolean; + /** The editor's accessible name, already translated. */ + label: string; + /** Shows **Cancel**, which calls it. */ + onCancel?: () => void; + /** Called after the server accepted the comment, with what it became. */ + onDone?: (outcome: ContentCommentWriteOutcome) => void; +} & ( + | { editable: ContentCommentEditable; id: ContentId; mode: "edit" } + | { mode: "create"; parentId: ContentId | null } +); + +const useBodySchema = (defaultBody: RichTextDocument) => { + const t = useTranslations("core.comments.form"); + + return z.object({ + body: zodRichTextDocument + .refine(doc => !isRichTextEmpty(doc), t("required")) + .default(defaultBody), + }); +}; + +/** + * The comment form: a new comment, a reply, or an edit. Sends through the + * thread's own callbacks with `useContentPublicWrite`, so a refused write + * keeps the text, shows the reason, and an edit with editorial revisions + * shows the version-conflict alert. Lazy-loaded by `ContentCommentComposer`. + */ +const ContentCommentComposerForm = (props: ContentCommentComposerFormProps) => { + const { autoFocus, label, onCancel, onDone } = props; + const t = useTranslations("core.comments"); + const { comments } = useContentCommentsContext(); + const editId = props.mode === "edit" ? props.id : null; + const write = useContentPublicWrite({ + fields: FORM_FIELDS, + latestVersion: + props.mode === "edit" && props.editable.version !== undefined + ? async () => { + if (editId === null) return null; + const result = await comments.loadEditable(editId); + + return result.ok ? (result.value.version ?? null) : null; + } + : undefined, + version: props.mode === "edit" ? props.editable.version : undefined, + }); + const formSchema = useBodySchema( + props.mode === "edit" ? props.editable.body : emptyRichTextDocument(), + ); + + const announce = (outcome: ContentCommentWriteOutcome) => { + const key = + props.mode === "edit" + ? outcome === "pending" + ? "edit_pending" + : "edited" + : outcome; + const show = outcome === "published" ? toast.success : toast.info; + show(t(`outcome.${key}.title`), { + description: t(`outcome.${key}.desc`), + }); + onDone?.(outcome); + }; + + const onSubmit: AutoFormOnSubmit = async ( + { body }, + form, + ) => { + const record = await write.submit(form, async expectedVersion => + props.mode === "edit" + ? await comments.requestSave(props.id, { body, expectedVersion }) + : await comments.requestCreate({ body, parentId: props.parentId }), + ); + if (!record) return; + + const outcome = await comments.written(record); + if (props.mode === "create") form.reset(); + announce(outcome); + }; + + return ( +
      + {write.conflict} + {props.mode === "edit" && props.editable.proposed ? ( + + {t("form.proposed")} + + ) : null} + ( + { + editor?.commands.focus("end"); + } + : undefined + } + /> + ), + }, + ]} + formSchema={formSchema} + layout={fields => <>{fields.body}} + mode="onSubmit" + onSubmit={onSubmit} + > +
      + + {props.mode === "edit" ? ( + + {onCancel ? ( + + ) : null} +
      +
      +
      + ); +}; + +export default ContentCommentComposerForm; diff --git a/packages/vitnode/src/tanstack/comments/content-comments.tsx b/packages/vitnode/src/tanstack/comments/content-comments.tsx new file mode 100644 index 0000000000..e205a8bc84 --- /dev/null +++ b/packages/vitnode/src/tanstack/comments/content-comments.tsx @@ -0,0 +1,648 @@ +import { Link, useRouterState } from "@tanstack/react-router"; +import { cn } from "cn"; +import { + ChevronDownIcon, + EyeOffIcon, + LogInIcon, + MessageSquareIcon, + PencilIcon, + ReplyIcon, + RotateCwIcon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { ContentId } from "@/content/ids"; + +import { Avatar } from "@/components/avatar"; +import { DateFormat } from "@/components/date-format"; +import { RichTextContent } from "@/components/rich-text/rich-text-content"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Skeleton } from "@/components/ui/skeleton"; +import { UserFormat } from "@/components/user-format"; +import { zodRichTextDocument } from "@/content/rich-text"; +import { useRevealHashTarget } from "@/hooks/use-reveal-hash-target"; + +import type { ContentCommentNode, ContentCommentTreeNode } from "./thread"; +import type { ContentCommentsController } from "./use-content-comments"; + +import { LOGIN_PATH, returnToFor } from "../auth/redirects"; +import { + ContentCommentActions, + contentCommentAuthor, + contentCommentAuthorName, + ContentCommentComposer, + preloadContentCommentComposer, + useContentCommentItem, +} from "./item"; +import { ContentCommentsRoot, useContentCommentsContext } from "./root"; + +const EDITED_AFTER_MS = 60_000; + +/** The `id` prefix of every comment element, for `#comment-...` links. */ +export const CONTENT_COMMENT_ANCHOR_PREFIX = "comment-"; + +/** How the default comments section links, labels and renders comments. */ +export interface ContentCommentsDisplay { + /** + * What follows `comment-` in a comment's element id, for links such as + * `#comment-42`. Defaults to the comment's id; return its slug to match + * permalinks built from slugs. + */ + anchorKey?: (record: TRecord) => string; + /** + * The link on a comment's date. Gets the element id, e.g. `comment-42`. + * Defaults to `#comment-42` on the current page. + */ + permalink?: (anchorId: string) => string; + /** Replaces parts of a comment. Each gets the comment's public record. */ + render?: { + /** Extra buttons in the row under the comment, next to **Reply**. */ + actions?: (record: TRecord) => React.ReactNode; + /** The author's name; defaults to the `author` field's person. */ + author?: (record: TRecord) => React.ReactNode; + /** The text; defaults to the rich-text `field`. */ + body?: (record: TRecord) => React.ReactNode; + /** Extra details after the date, such as a badge. */ + meta?: (record: TRecord) => React.ReactNode; + }; +} + +type AnyDisplay = ContentCommentsDisplay; + +const recordField = (record: unknown, name: string): unknown => + typeof record === "object" && record !== null && name in record + ? (record as Record)[name] + : undefined; + +const readDate = (record: unknown, name: string): null | string => { + const value = recordField(record, name); + + return typeof value === "string" ? value : null; +}; + +/** The element id of a comment, e.g. `comment-42`. */ +const anchorIdOf = ( + node: ContentCommentNode, + display: AnyDisplay, +): null | string => + node.record + ? `${CONTENT_COMMENT_ANCHOR_PREFIX}${display.anchorKey ? display.anchorKey(node.record) : String(node.id)}` + : null; + +const CommentBody = ({ record }: { record: unknown }) => { + const { comments } = useContentCommentsContext(); + const parsed = zodRichTextDocument.safeParse( + recordField(record, comments.field), + ); + + return parsed.success ? ( + + ) : null; +}; + +/** + * The anonymous stand-in for a comment that was deleted, or hidden from this + * reader, while it still had replies. Shows no text and no author. + */ +export const ContentCommentPlaceholder = ({ + className, + node, +}: { + className?: string; + node: ContentCommentNode; +}) => { + const t = useTranslations("core.comments"); + const { deleted } = node; + + return ( +
      +

      + {t("placeholder.name")} + {deleted ? ( + + + + + ) : null} +

      +

      + {deleted + ? deleted.byAuthor + ? t("placeholder.by_author") + : t("placeholder.by_moderator") + : t("removed")} +

      +
      + ); +}; + +/** + * One comment as the default section draws it: author, date, text and hidden + * state, the **Reply** button, the actions menu, and the edit and reply forms + * in place. A placeholder stands in for a removed comment. + */ +export const ContentCommentItem = ({ + display = {}, + highlighted = false, + node, +}: { + display?: ContentCommentsDisplay; + /** Rings the comment a `#comment-...` link pointed to. */ + highlighted?: boolean; + node: ContentCommentNode; +}) => { + const t = useTranslations("core.comments"); + const item = useContentCommentItem(node); + const options = display as AnyDisplay; + const { record } = node; + + if (!record) { + return ; + } + + const anchorId = anchorIdOf(node, options) ?? ""; + const authorId = `${anchorId}-author`; + const person = contentCommentAuthor(record); + const name = contentCommentAuthorName(person); + const publishedAt = readDate(record, "publishedAt"); + const updatedAt = readDate(record, "updatedAt"); + const date = publishedAt ?? readDate(record, "createdAt"); + const isEdited = + publishedAt !== null && + updatedAt !== null && + new Date(updatedAt).getTime() - new Date(publishedAt).getTime() > + EDITED_AFTER_MS; + + if (item.editing) { + return ( + + ); + } + + return ( +
      + {person ? ( + + ) : ( +
      + ); +}; + +/** Deeper replies stop indenting here, so narrow screens keep their width. */ +const INDENT_UNTIL_DEPTH = 3; + +const CommentBranch = ({ + display, + highlightedId, + node, +}: { + display: AnyDisplay; + highlightedId: null | string; + node: ContentCommentTreeNode; +}) => { + const anchorId = anchorIdOf(node, display); + + return ( +
    3. + + {node.children.length > 0 ? ( +
        + {node.children.map(child => ( + + ))} +
      + ) : null} +
    4. + ); +}; + +const COMMENT_SKELETON_KEYS = ["first", "second", "third"]; + +const CommentsSkeleton = ({ label }: { label: string }) => ( +
      + + {label} + + {COMMENT_SKELETON_KEYS.map(key => ( + + ))} +
      +); + +/** + * **Load more comments**, shown while more top-level comments wait on the + * server. Tells the reader when loading failed. + */ +export const ContentCommentsLoadMore = ({ + className, +}: { + className?: string; +}) => { + const t = useTranslations("core.comments"); + const { comments } = useContentCommentsContext(); + if (!comments.hasMore) return null; + + return ( + + ); +}; + +/** + * The new-comment card: the composer for a signed-in visitor, a sign-in link + * for a guest that returns here, or nothing when the thread has no `create`. + */ +export const ContentCommentsComposer = ({ + className, +}: { + className?: string; +}) => { + const t = useTranslations("core.comments"); + const { comments } = useContentCommentsContext(); + const returnTo = useRouterState({ + select: state => returnToFor(state.location), + }); + const [state, setState] = React.useState<"closed" | "open" | "opening">( + "closed", + ); + const [formKey, setFormKey] = React.useState(0); + if (!comments.features.create) return null; + + return ( +
      +

      {t("compose.title")}

      + {comments.isSessionPending ? ( +
      + ); +}; + +const ContentCommentsSection = ({ + className, + display, + title, +}: { + className?: string; + display: AnyDisplay; + title?: React.ReactNode; +}) => { + const t = useTranslations("core.comments"); + const { comments } = useContentCommentsContext(); + const headingId = React.useId(); + const anchor = useRevealHashTarget({ + hasMore: comments.hasMore, + isLoadingMore: comments.isLoadingMore, + loadMore: comments.loadMore, + prefix: CONTENT_COMMENT_ANCHOR_PREFIX, + ready: comments.status === "success", + renderedCount: comments.items.length, + }); + const highlightedId = anchor.status === "found" ? anchor.targetId : null; + + return ( +
      +

      +

      + + {comments.status === "pending" ? ( + + ) : comments.status === "error" ? ( +
      +

      + {t("load_failed.title")}. {t("load_failed.desc")} +

      + +
      + ) : comments.tree.length === 0 ? ( + + + + + }>{t("empty.title")} + {t("empty.desc")} + + + ) : ( +
        + {comments.tree.map(node => ( + + ))} +
      + )} + + {anchor.status === "missing" ? ( +

      + {t("anchor_missing")} +

      + ) : null} + {comments.truncated ? ( +

      + {t("truncated")} +

      + ) : null} + + +
      + ); +}; + +/** + * A complete comment section for one Content Engine record: the count, the + * thread with replies, load more, the composer or a sign-in link, and every + * action the plugin wired into `useContentComments` and the server allows. + * Scrolls to and rings the comment a `#comment-...` link points to, loading + * more pages until it appears. + * + * For your own layout, use `ContentCommentsRoot` with `ContentCommentItem`, + * `ContentCommentActions` and `ContentCommentComposer` instead. + * + * @example + * ```tsx + * + * ``` + */ +export const ContentComments = ({ + className, + comments, + title, + ...display +}: ContentCommentsDisplay & { + className?: string; + /** From `useContentComments`. */ + comments: ContentCommentsController; + /** Replaces the "N comments" heading. */ + title?: React.ReactNode; +}) => { + const t = useTranslations("core.global"); + + return ( + } + > + + + ); +}; diff --git a/packages/vitnode/src/tanstack/comments/hide-form.tsx b/packages/vitnode/src/tanstack/comments/hide-form.tsx new file mode 100644 index 0000000000..b7894c5b9d --- /dev/null +++ b/packages/vitnode/src/tanstack/comments/hide-form.tsx @@ -0,0 +1,76 @@ +import { EyeOffIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { + AutoForm, + type AutoFormOnSubmit, + AutoFormSubmitButton, +} from "@/components/form/auto-form"; +import { AutoFormTextarea } from "@/components/form/fields/textarea"; +import { Button } from "@/components/ui/button"; +import { CONTENT_HIDDEN_REASON_MAX_LENGTH } from "@/content/const"; + +export interface ContentCommentHideFormProps { + onCancel: () => void; + /** Hides the comment; `reason` is `null` when left empty. */ + onHide: (reason: null | string) => Promise; +} + +const formSchema = z.object({ + hideReason: z + .string() + .trim() + .max(CONTENT_HIDDEN_REASON_MAX_LENGTH) + .default(""), +}); + +/** The optional reason asked before hiding a comment. Lazy-loaded. */ +const ContentCommentHideForm = ({ + onCancel, + onHide, +}: ContentCommentHideFormProps) => { + const t = useTranslations("core.comments"); + + const onSubmit: AutoFormOnSubmit = async ({ + hideReason, + }) => { + await onHide(hideReason === "" ? null : hideReason); + }; + + return ( + ( + + ), + }, + ]} + formSchema={formSchema} + layout={fields => <>{fields.hideReason}} + mode="onSubmit" + onSubmit={onSubmit} + > +
      + + + +
      +
      + ); +}; + +export default ContentCommentHideForm; diff --git a/packages/vitnode/src/tanstack/comments/index.ts b/packages/vitnode/src/tanstack/comments/index.ts new file mode 100644 index 0000000000..d2674e2d49 --- /dev/null +++ b/packages/vitnode/src/tanstack/comments/index.ts @@ -0,0 +1,50 @@ +export type { ContentCommentComposerFormProps as ContentCommentComposerProps } from "./composer-form"; +export type { ContentCommentsDisplay } from "./content-comments"; +export { + CONTENT_COMMENT_ANCHOR_PREFIX, + ContentCommentItem, + ContentCommentPlaceholder, + ContentComments, + ContentCommentsComposer, + ContentCommentsLoadMore, +} from "./content-comments"; +export type { ContentCommentItemState } from "./item"; +export { + ContentCommentActions, + contentCommentAuthor, + contentCommentAuthorName, + ContentCommentComposer, + preloadContentCommentComposer, + useContentCommentItem, +} from "./item"; +export { + CONTENT_COMMENTS_NAMESPACES, + ContentCommentsRoot, + useContentCommentErrorToast, + useContentCommentsContext, +} from "./root"; +export type { + ContentCommentNode, + ContentCommentsThreadPage, + ContentCommentTreeNode, + ContentCommentWriteOutcome, +} from "./thread"; +export { + buildContentCommentTree, + contentCommentWriteOutcome, + countContentCommentReplies, +} from "./thread"; +export type { + ContentCommentActionResult, + ContentCommentBusy, + ContentCommentEditable, + ContentCommentIdOf, + ContentCommentRecordOf, + ContentCommentsApiResponse, + ContentCommentsConfig, + ContentCommentsController, +} from "./use-content-comments"; +export { + contentCommentsQueryOptions, + useContentComments, +} from "./use-content-comments"; diff --git a/packages/vitnode/src/tanstack/comments/item.tsx b/packages/vitnode/src/tanstack/comments/item.tsx new file mode 100644 index 0000000000..135fd3ce22 --- /dev/null +++ b/packages/vitnode/src/tanstack/comments/item.tsx @@ -0,0 +1,375 @@ +import { cn } from "cn"; +import { + EllipsisIcon, + EyeIcon, + EyeOffIcon, + FlagIcon, + PencilIcon, + Trash2Icon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { ContentPublicPerson } from "@/content/public-person"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, +} from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; +import { zodContentPublicPerson } from "@/content/public-person"; + +import type { ContentCommentComposerFormProps } from "./composer-form"; +import type { ContentCommentHideFormProps } from "./hide-form"; +import type { ContentCommentNode } from "./thread"; +import type { ContentCommentEditable } from "./use-content-comments"; + +import { useContentCommentErrorToast, useContentCommentsContext } from "./root"; + +const loadComposer = async () => await import("./composer-form"); +const ComposerForm = React.lazy(loadComposer); + +/** Starts loading the comment form, e.g. when the pointer reaches **Reply**. */ +export const preloadContentCommentComposer = () => { + void loadComposer(); +}; + +/** + * The comment form - a new comment, a reply or an edit - loaded on first use + * with a skeleton of its size. Render it inside a comments root. + */ +export const ContentCommentComposer = ( + props: ContentCommentComposerFormProps, +) => ( + +); + +const loadHideForm = async () => await import("./hide-form"); +const HideForm = React.lazy(loadHideForm); + +const LazyHideForm = (props: ContentCommentHideFormProps) => ( + +); + +const zodAuthor = zodContentPublicPerson.nullable().catch(null); + +/** + * The comment's author from its public record's `author` field, or `null` + * for a deleted account or a content type without one. Pure. + */ +export const contentCommentAuthor = ( + record: unknown, +): ContentPublicPerson | null => + typeof record === "object" && record !== null && "author" in record + ? zodAuthor.parse(record.author) + : null; + +/** The name to show for a person, preferring a real name they share. Pure. */ +export const contentCommentAuthorName = ( + person: ContentPublicPerson | null, +): null | string => (person ? (person.fullName ?? person.name) : null); + +/** + * The state one comment needs: what the visitor may do with it, whether an + * action runs on it, and its edit and reply forms. Use it to build your own + * comment; `ContentComments` uses it for every comment it renders. + */ +export const useContentCommentItem = (node: ContentCommentNode) => { + const { comments } = useContentCommentsContext(); + const showError = useContentCommentErrorToast(); + const [editing, setEditing] = React.useState( + null, + ); + const [isReplying, setIsReplying] = React.useState(false); + const permissions = comments.permissionsOf(node.id); + const isBusy = comments.busy !== null && comments.busy.id === node.id; + + return { + /** Whether **Reply** shows: the visitor may write, under `maxDepth`, not hidden. */ + canReply: comments.canReply(node), + /** The values the edit form opened with, or `null` when not editing. */ + editing, + /** `true` while any action runs on the thread; start no other. */ + isAnyBusy: comments.busy !== null, + /** `true` while an action runs on this comment. */ + isBusy, + isReplying, + /** The server's answers, limited to the actions the plugin wired up. */ + permissions: { + canDelete: comments.features.delete && permissions.canDelete, + canEdit: comments.features.edit && permissions.canEdit, + canHide: comments.features.hide && permissions.canHide, + canReport: comments.features.report && permissions.canReport, + }, + setIsReplying, + /** Loads the editable values, then opens the edit form. */ + startEditing: async () => { + if (comments.busy) return; + comments.setEditing(node.id); + try { + const result = await comments.loadEditable(node.id); + if (result.ok) setEditing(result.value); + else showError(result.status); + } finally { + comments.setEditing(null); + } + }, + stopEditing: () => { + setEditing(null); + }, + }; +}; + +/** What `useContentCommentItem` returns. */ +export type ContentCommentItemState = ReturnType; + +interface MenuOrigin { + height: number; + width: number; +} + +const HIDE_PROMPT_MORPH_CLASS = + "[clip-path:inset(-2rem_round_var(--radius-lg))] transition-[opacity,scale,clip-path] duration-200 data-starting-style:scale-100 data-starting-style:[clip-path:inset(0_calc(100%-var(--menu-width,100%))_calc(100%-var(--menu-height,100%))_0_round_var(--radius-lg))] data-[side=top]:data-starting-style:[clip-path:inset(calc(100%-var(--menu-height,100%))_calc(100%-var(--menu-width,100%))_0_0_round_var(--radius-lg))]"; + +/** + * The **More actions** menu of one comment: Edit, Report, Hide or Unhide and + * Delete, each shown only when the plugin wired it up and the server allows + * it. Hiding asks for an optional reason in a popover; deleting asks for + * confirmation. Renders nothing when no action is available. + * + * `children` adds your own menu items above the shared ones. + */ +export const ContentCommentActions = ({ + children, + className, + item, + node, +}: { + children?: React.ReactNode; + className?: string; + /** The comment's state from `useContentCommentItem`. */ + item: ContentCommentItemState; + node: ContentCommentNode; +}) => { + const t = useTranslations("core.comments"); + const { comments, requestDelete, requestReport } = + useContentCommentsContext(); + const showError = useContentCommentErrorToast(); + const menuRef = React.useRef(null); + const triggerRef = React.useRef(null); + const [isHidePromptOpen, setIsHidePromptOpen] = React.useState(false); + const [hidePromptOrigin, setHidePromptOrigin] = + React.useState(null); + const { canDelete, canEdit, canHide, canReport } = item.permissions; + const isHidden = node.hidden; + const name = + contentCommentAuthorName(contentCommentAuthor(node.record)) ?? + t("deleted_user"); + + if (!node.record || (!canEdit && !canReport && !canHide && !canDelete)) { + return children ? ( + + {children} + + ) : null; + } + + const preload = () => { + if (canEdit) preloadContentCommentComposer(); + if (canHide && !isHidden) void loadHideForm(); + }; + + const changeVisibility = async (reason: null | string = null) => { + const result = isHidden + ? await comments.unhide(node.id) + : await comments.hide(node.id, reason); + if (!result.ok) { + showError(result.status); + + return false; + } + + const action = isHidden ? "unhide" : "hide"; + toast.success(t(`visibility.${action}.title`), { + description: t(`visibility.${action}.desc`), + }); + + return true; + }; + + return ( + <> + + + } + > + + + {children} + {canEdit ? ( + { + void item.startEditing(); + }} + > + + ) : null} + {canReport ? ( + { + requestReport(node); + }} + > + + ) : null} + {(canHide || canDelete) && (canEdit || canReport || children) ? ( + + ) : null} + {canHide ? ( + { + if (isHidden) { + void changeVisibility(); + + return; + } + const rect = menuRef.current?.getBoundingClientRect(); + setHidePromptOrigin( + rect ? { height: rect.height, width: rect.width } : null, + ); + setIsHidePromptOpen(true); + }} + > + {isHidden ? ( + + ) : null} + {canDelete ? ( + { + requestDelete(node); + }} + variant="destructive" + > + + ) : null} + + + {canHide && !isHidden ? ( + + + + {t("visibility.hide.prompt_title")} + + {t("visibility.hide.prompt_desc")} + + + { + setIsHidePromptOpen(false); + }} + onHide={async reason => { + if (await changeVisibility(reason)) { + setIsHidePromptOpen(false); + } + }} + /> + + + ) : null} + + ); +}; + +const DropdownMenuShell = ({ + children, + className, + label, +}: { + children: React.ReactNode; + className?: string; + label: string; +}) => ( + + + } + > + + {children} + +); diff --git a/packages/vitnode/src/tanstack/comments/root.tsx b/packages/vitnode/src/tanstack/comments/root.tsx new file mode 100644 index 0000000000..33a5aa8739 --- /dev/null +++ b/packages/vitnode/src/tanstack/comments/root.tsx @@ -0,0 +1,237 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { Trash2Icon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { IntlProvider, useMessages, useTranslations } from "use-intl"; + +import type { ContentId } from "@/content/ids"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { deepMerge } from "@/lib/i18n/deep-merge"; +import { IntlProvider as CoreIntlProvider } from "@/lib/i18n/provider"; + +import type { ContentCommentNode } from "./thread"; +import type { ContentCommentsController } from "./use-content-comments"; + +import { useLocale } from "../i18n/locale"; +import { intlQueryOptions } from "../i18n/query"; +import { getIntlRuntime } from "../i18n/runtime"; +import { ReportDialog } from "../moderation/report-button"; + +/** The message namespaces the shared comments components render with. */ +export const CONTENT_COMMENTS_NAMESPACES = [ + "core.global", + "core.comments", +] as const; + +interface ContentCommentsContextValue { + comments: ContentCommentsController; + /** Opens the delete confirmation for one comment. */ + requestDelete: (node: ContentCommentNode) => void; + /** Opens the report dialog for one comment. */ + requestReport: (node: ContentCommentNode) => void; +} + +/** + * Adds the comment messages to the ones the page already provides, so a + * plugin's own layout inside a comments root keeps its translations. + */ +const CommentsMessages = ({ children }: { children: React.ReactNode }) => { + const locale = useLocale(); + const pageMessages = useMessages(); + const { data } = useSuspenseQuery( + intlQueryOptions({ locale, namespaces: CONTENT_COMMENTS_NAMESPACES }), + ); + const { hostIntlProvider, timeZone } = getIntlRuntime(); + const messages = React.useMemo( + () => deepMerge(pageMessages, data.messages), + [data.messages, pageMessages], + ); + const intlProps = { locale, messages, timeZone }; + + const provided = ( + + {children} + + ); + + if (!hostIntlProvider) return provided; + + return React.createElement(hostIntlProvider, { + ...intlProps, + // oxlint-disable-next-line @eslint-react/jsx-no-children-prop + children: provided, + }); +}; + +const ContentCommentsContext = + React.createContext(null); + +/** + * The thread `ContentCommentsRoot` provides, for components that render one + * of its comments. Throws outside a root. + */ +export const useContentCommentsContext = (): ContentCommentsContextValue => { + const value = React.use(ContentCommentsContext); + if (!value) { + throw new Error( + "Render shared comment components inside or .", + ); + } + + return value; +}; + +/** + * Turns an action's failed status into one translated toast. `409` means + * another action on the thread was still running. + */ +export const useContentCommentErrorToast = () => { + const t = useTranslations("core.comments.errors"); + + return (status: number) => { + const description = + status === 401 + ? t("unauthenticated") + : status === 403 + ? t("forbidden") + : status === 404 + ? t("not_found") + : status === 409 + ? t("busy") + : t("unknown"); + toast.error(t("title"), { description }); + }; +}; + +const RootDialogs = ({ + deleting, + onDeleteClosed, + onReportClosed, + reporting, +}: { + deleting: ContentCommentNode | null; + onDeleteClosed: () => void; + onReportClosed: () => void; + reporting: ContentCommentNode | null; +}) => { + const t = useTranslations("core.comments"); + const { comments } = useContentCommentsContext(); + const showError = useContentCommentErrorToast(); + const target = reporting ? comments.reportTarget(reporting.id) : null; + + return ( + <> + } + onOpenChange={open => { + if (!open) onDeleteClosed(); + }} + onSubmit={async ({ onClose }) => { + if (!deleting) return; + const result = await comments.remove(deleting.id); + if (!result.ok) { + showError(result.status); + + return; + } + + toast.success(t("delete.done.title"), { + description: t("delete.done.desc"), + }); + onClose(); + }} + open={deleting !== null} + textSubmit={ + <> +