diff --git a/apps/api/migrations/20261013155118_add_container_policies_approvals_forum/migration.sql b/apps/api/migrations/20261013155118_add_container_policies_approvals_forum/migration.sql new file mode 100644 index 0000000000..0f320e8070 --- /dev/null +++ b/apps/api/migrations/20261013155118_add_container_policies_approvals_forum/migration.sql @@ -0,0 +1,87 @@ +CREATE TABLE "core_content_submissions" ( + "id" serial PRIMARY KEY, + "pluginId" varchar(255) NOT NULL, + "contentTypeId" varchar(100) NOT NULL, + "itemId" varchar(64) NOT NULL, + "containerDefinitionId" varchar(100), + "containerId" integer, + "kind" varchar(16) NOT NULL, + "status" varchar(16) DEFAULT 'pending' NOT NULL, + "values" jsonb DEFAULT '{}' NOT NULL, + "baseValues" jsonb, + "baseVersion" integer, + "baseUpdatedAt" timestamp, + "authorId" integer, + "reviewerId" integer, + "reason" text, + "revisionId" integer, + "decidedAt" timestamp, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_content_submissions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "forum_replies" ( + "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, + "slug" varchar(160) NOT NULL, + "body" jsonb NOT NULL, + "author" integer, + "topic" integer NOT NULL, + "parent" integer +); +--> statement-breakpoint +ALTER TABLE "forum_replies" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "forum_topics" ( + "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, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "body" jsonb NOT NULL, + "author" integer, + "forum" integer NOT NULL +); +--> statement-breakpoint +ALTER TABLE "forum_topics" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "core_content_containers" ADD COLUMN "policies" jsonb DEFAULT '{}' NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_submissions_pending_key" ON "core_content_submissions" ("contentTypeId","itemId") WHERE "status" = 'pending';--> statement-breakpoint +CREATE INDEX "core_content_submissions_queue_idx" ON "core_content_submissions" ("status","contentTypeId","containerId","createdAt");--> statement-breakpoint +CREATE INDEX "core_content_submissions_item_idx" ON "core_content_submissions" ("contentTypeId","itemId");--> statement-breakpoint +CREATE INDEX "core_content_submissions_author_idx" ON "core_content_submissions" ("authorId","createdAt");--> statement-breakpoint +CREATE INDEX "forum_replies_topic_parent_created_at_idx" ON "forum_replies" ("topic","parent","createdAt");--> statement-breakpoint +CREATE UNIQUE INDEX "forum_replies_slug_key" ON "forum_replies" ("slug");--> statement-breakpoint +CREATE INDEX "forum_replies_author_idx" ON "forum_replies" ("author");--> statement-breakpoint +CREATE INDEX "forum_replies_topic_idx" ON "forum_replies" ("topic");--> statement-breakpoint +CREATE INDEX "forum_replies_parent_idx" ON "forum_replies" ("parent");--> statement-breakpoint +CREATE INDEX "forum_replies_created_at_idx" ON "forum_replies" ("createdAt");--> statement-breakpoint +CREATE INDEX "forum_replies_updated_at_idx" ON "forum_replies" ("updatedAt");--> statement-breakpoint +CREATE INDEX "forum_replies_status_published_at_idx" ON "forum_replies" ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "forum_replies_hidden_by_idx" ON "forum_replies" ("hiddenBy");--> statement-breakpoint +CREATE INDEX "forum_topics_forum_published_at_idx" ON "forum_topics" ("forum","publishedAt");--> statement-breakpoint +CREATE UNIQUE INDEX "forum_topics_slug_key" ON "forum_topics" ("slug");--> statement-breakpoint +CREATE INDEX "forum_topics_author_idx" ON "forum_topics" ("author");--> statement-breakpoint +CREATE INDEX "forum_topics_created_at_idx" ON "forum_topics" ("createdAt");--> statement-breakpoint +CREATE INDEX "forum_topics_updated_at_idx" ON "forum_topics" ("updatedAt");--> statement-breakpoint +CREATE INDEX "forum_topics_status_published_at_idx" ON "forum_topics" ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "forum_topics_hidden_by_idx" ON "forum_topics" ("hiddenBy");--> statement-breakpoint +ALTER TABLE "core_content_submissions" ADD CONSTRAINT "core_content_submissions_authorId_core_users_id_fkey" FOREIGN KEY ("authorId") REFERENCES "core_users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_content_submissions" ADD CONSTRAINT "core_content_submissions_reviewerId_core_users_id_fkey" FOREIGN KEY ("reviewerId") REFERENCES "core_users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "forum_replies" ADD CONSTRAINT "forum_replies_hiddenBy_core_users_id_fkey" FOREIGN KEY ("hiddenBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "forum_replies" ADD CONSTRAINT "forum_replies_author_core_users_id_fkey" FOREIGN KEY ("author") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "forum_replies" ADD CONSTRAINT "forum_replies_topic_forum_topics_id_fkey" FOREIGN KEY ("topic") REFERENCES "forum_topics"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "forum_replies" ADD CONSTRAINT "forum_replies_parent_forum_replies_id_fkey" FOREIGN KEY ("parent") REFERENCES "forum_replies"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "forum_topics" ADD CONSTRAINT "forum_topics_hiddenBy_core_users_id_fkey" FOREIGN KEY ("hiddenBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "forum_topics" ADD CONSTRAINT "forum_topics_author_core_users_id_fkey" FOREIGN KEY ("author") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "forum_topics" ADD CONSTRAINT "forum_topics_forum_core_content_containers_id_fkey" FOREIGN KEY ("forum") REFERENCES "core_content_containers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/apps/api/migrations/20261013155118_add_container_policies_approvals_forum/snapshot.json b/apps/api/migrations/20261013155118_add_container_policies_approvals_forum/snapshot.json new file mode 100644 index 0000000000..0c7a410485 --- /dev/null +++ b/apps/api/migrations/20261013155118_add_container_policies_approvals_forum/snapshot.json @@ -0,0 +1,19647 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "9ddac84b-1d56-431b-afab-e8e20b8f1731", + "prevIds": [ + "72797947-1eaf-4920-aae7-3bba9cbd49eb" + ], + "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_reports", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_discussions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_related_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_related_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_replies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_topics", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "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_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": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isHidden", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "imageId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "board", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "example_events_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primaryTag", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_replies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_replies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_replies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_replies" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_replies" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_replies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_replies" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_replies" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_replies" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_replies" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_replies" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "topic", + "entityType": "columns", + "schema": "public", + "table": "forum_replies" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "forum_replies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "forum", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "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 + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "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": "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": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "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": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "board", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_board_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primaryTag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_primary_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_sessions_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_replies_topic_parent_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_replies" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_replies_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_replies" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_replies_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_replies" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_replies_topic_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_replies" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_replies_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_replies" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_replies_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_replies" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_replies_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_replies" + }, + { + "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_replies_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_replies" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_replies_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_replies" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "forum", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_forum_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_translation_sources_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "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": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "imageId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_search_index_imageId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_posts_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_tags_ATchHLRtvoCM_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_tags_dEsWdkDmu7nW_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_discussions_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_discussions_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "board" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_discussions_board_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "primaryTag" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_primaryTag_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_gallery_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_oaRvqnKyAS7L_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_sessions_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_tags_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_translations_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_translations_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_tags_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_replies" + }, + { + "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_replies" + }, + { + "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_replies" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "forum_replies", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_parent_forum_replies_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_replies" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "forum" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "forum_topics_forum_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_related_events_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_events_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_translations" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_tags_related_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_tags_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_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_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_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_discussions_pkey", + "schema": "public", + "table": "example_discussions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_pkey", + "schema": "public", + "table": "example_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_sessions_pkey", + "schema": "public", + "table": "example_events_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_tags_pkey", + "schema": "public", + "table": "example_tags", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_replies_pkey", + "schema": "public", + "table": "forum_replies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_topics_pkey", + "schema": "public", + "table": "forum_topics", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "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/api/migrations/20261013173105_rename_forum_replies_to_comments/migration.sql b/apps/api/migrations/20261013173105_rename_forum_replies_to_comments/migration.sql new file mode 100644 index 0000000000..1bdd66b42d --- /dev/null +++ b/apps/api/migrations/20261013173105_rename_forum_replies_to_comments/migration.sql @@ -0,0 +1,21 @@ +ALTER TABLE "forum_replies" RENAME TO "forum_comments";--> statement-breakpoint +ALTER INDEX "forum_replies_topic_parent_created_at_idx" RENAME TO "forum_comments_topic_parent_created_at_idx";--> statement-breakpoint +ALTER INDEX "forum_replies_slug_key" RENAME TO "forum_comments_slug_key";--> statement-breakpoint +ALTER INDEX "forum_replies_author_idx" RENAME TO "forum_comments_author_idx";--> statement-breakpoint +ALTER INDEX "forum_replies_topic_idx" RENAME TO "forum_comments_topic_idx";--> statement-breakpoint +ALTER INDEX "forum_replies_parent_idx" RENAME TO "forum_comments_parent_idx";--> statement-breakpoint +ALTER INDEX "forum_replies_created_at_idx" RENAME TO "forum_comments_created_at_idx";--> statement-breakpoint +ALTER INDEX "forum_replies_updated_at_idx" RENAME TO "forum_comments_updated_at_idx";--> statement-breakpoint +ALTER INDEX "forum_replies_status_published_at_idx" RENAME TO "forum_comments_status_published_at_idx";--> statement-breakpoint +ALTER INDEX "forum_replies_hidden_by_idx" RENAME TO "forum_comments_hidden_by_idx";--> statement-breakpoint +CREATE INDEX "core_search_index_container_idx" ON "core_search_index" ("containerType","containerId");--> statement-breakpoint +UPDATE "core_content_revisions" SET "contentTypeId" = 'forum.comment' WHERE "contentTypeId" = 'forum.reply';--> statement-breakpoint +UPDATE "core_content_submissions" SET "contentTypeId" = 'forum.comment' WHERE "contentTypeId" = 'forum.reply';--> statement-breakpoint +UPDATE "core_content_schedules" SET "contentTypeId" = 'forum.comment' WHERE "contentTypeId" = 'forum.reply';--> statement-breakpoint +UPDATE "core_content_slug_history" SET "contentTypeId" = 'forum.comment' WHERE "contentTypeId" = 'forum.reply';--> statement-breakpoint +UPDATE "core_content_documents" SET "contentTypeId" = 'forum.comment' WHERE "contentTypeId" = 'forum.reply';--> statement-breakpoint +UPDATE "core_content_drafts" SET "contentTypeId" = 'forum.comment' WHERE "contentTypeId" = 'forum.reply';--> statement-breakpoint +UPDATE "core_content_field_locks" SET "contentTypeId" = 'forum.comment' WHERE "contentTypeId" = 'forum.reply';--> statement-breakpoint +UPDATE "core_ai_translation_sources" SET "contentTypeId" = 'forum.comment' WHERE "contentTypeId" = 'forum.reply';--> statement-breakpoint +UPDATE "core_moderation_reports" SET "targetType" = 'forum.comment' WHERE "targetType" = 'forum.reply';--> statement-breakpoint +DELETE FROM "core_search_index" WHERE "itemType" = 'forum.reply'; diff --git a/apps/api/migrations/20261013173105_rename_forum_replies_to_comments/snapshot.json b/apps/api/migrations/20261013173105_rename_forum_replies_to_comments/snapshot.json new file mode 100644 index 0000000000..d787298252 --- /dev/null +++ b/apps/api/migrations/20261013173105_rename_forum_replies_to_comments/snapshot.json @@ -0,0 +1,19686 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "b9feeb04-d716-42fb-8a93-ddced63b0b5b", + "prevIds": [ + "9ddac84b-1d56-431b-afab-e8e20b8f1731" + ], + "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_reports", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_discussions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_related_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_related_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_topics", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "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_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": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isHidden", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "imageId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "board", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "example_events_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primaryTag", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "topic", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "forum", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "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 + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "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": "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": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "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": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "board", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_board_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primaryTag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_primary_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_sessions_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_topic_parent_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_topic_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "forum", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_forum_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_translation_sources_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "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": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "imageId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_search_index_imageId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_posts_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_tags_ATchHLRtvoCM_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_tags_dEsWdkDmu7nW_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_discussions_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_discussions_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "board" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_discussions_board_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "primaryTag" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_primaryTag_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_gallery_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_oaRvqnKyAS7L_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_sessions_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_tags_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_translations_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_translations_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_tags_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "topic" + ], + "schemaTo": "public", + "tableTo": "forum_topics", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "forum_replies_topic_forum_topics_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "forum_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_parent_forum_replies_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "forum" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "forum_topics_forum_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_related_events_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_events_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_translations" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_tags_related_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_tags_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_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_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_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_discussions_pkey", + "schema": "public", + "table": "example_discussions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_pkey", + "schema": "public", + "table": "example_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_sessions_pkey", + "schema": "public", + "table": "example_events_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_tags_pkey", + "schema": "public", + "table": "example_tags", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_replies_pkey", + "schema": "public", + "table": "forum_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_topics_pkey", + "schema": "public", + "table": "forum_topics", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "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": [ + "public.forum_replies->public.forum_comments", + "public.forum_comments.forum_replies_topic_parent_created_at_idx->public.forum_comments.forum_comments_topic_parent_created_at_idx", + "public.forum_comments.forum_replies_slug_key->public.forum_comments.forum_comments_slug_key", + "public.forum_comments.forum_replies_author_idx->public.forum_comments.forum_comments_author_idx", + "public.forum_comments.forum_replies_topic_idx->public.forum_comments.forum_comments_topic_idx", + "public.forum_comments.forum_replies_parent_idx->public.forum_comments.forum_comments_parent_idx", + "public.forum_comments.forum_replies_created_at_idx->public.forum_comments.forum_comments_created_at_idx", + "public.forum_comments.forum_replies_updated_at_idx->public.forum_comments.forum_comments_updated_at_idx", + "public.forum_comments.forum_replies_status_published_at_idx->public.forum_comments.forum_comments_status_published_at_idx", + "public.forum_comments.forum_replies_hidden_by_idx->public.forum_comments.forum_comments_hidden_by_idx" + ] +} \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 4aceaace5f..7f09552ebc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -46,6 +46,7 @@ "@vitnode/blog": "workspace:*", "@vitnode/config": "workspace:*", "@vitnode/example": "workspace:*", + "@vitnode/forum": "workspace:*", "@vitnode/nodemailer": "workspace:*", "dotenv": "^18.0.6", "oxlint": "^1.87.0", diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts index 1304f9d0ae..a76a8d4fcf 100644 --- a/apps/api/src/vitnode.api.config.ts +++ b/apps/api/src/vitnode.api.config.ts @@ -7,6 +7,7 @@ import { coreRelations } from "@vitnode/core/database/relations"; // import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local"; import { buildApiConfig } from "@vitnode/core/vitnode.config"; import { exampleApiPlugin } from "@vitnode/example/config.api"; +import { forumApiPlugin } from "@vitnode/forum/config.api"; import { NodeCronAdapter } from "@vitnode/node-cron"; import { NodemailerEmailAdapter } from "@vitnode/nodemailer"; // import { S3StorageAdapter } from "@vitnode/s3"; @@ -24,7 +25,7 @@ export const POSTGRES_URL = process.env.POSTGRES_URL ?? "postgresql://root:root@localhost:5432/vitnode"; export const vitNodeApiConfig = buildApiConfig({ - plugins: [blogApiPlugin(), exampleApiPlugin()], + plugins: [blogApiPlugin(), exampleApiPlugin(), forumApiPlugin()], ai: { models: [ { diff --git a/apps/web/content/docs/content-engine/approvals.mdx b/apps/web/content/docs/content-engine/approvals.mdx new file mode 100644 index 0000000000..dd574bf161 --- /dev/null +++ b/apps/web/content/docs/content-engine/approvals.mdx @@ -0,0 +1,112 @@ +--- +title: Approvals +description: How the VitNode Content Engine approval workflow works. New records wait as drafts, edits of live records wait as proposals, moderators approve or reject them, and authors follow their submissions through the approval field and /moderation/approvals/mine. +icon: ClipboardCheck +--- + +import { TypeTable } from "fumadocs-ui/components/type-table"; + +When a container's [publishing policy](/docs/content-engine/container-policies) says `publish: "approve"` or `edits: "approve"`, a member's post does not go live straight away. The server stores a **submission**, a moderator decides, and only an approval changes what readers see. This page explains what waits where, what the author sees, and what a decision does. The moderator's side is in [Review approvals](/docs/dev/moderation/review-approvals). + +## What waits, and where + +The server decides from the effective policy of the record's container. The client never sends a status. + +| The author | What is stored | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| Creates a topic in an `approve` forum | A `draft` row plus a `create` submission. Nothing public can reach a draft | +| Edits a published topic, with `edits: "approve"` | An `edit` submission holding the proposed values. The live row and its `version` do not change | +| Edits again while that proposal is pending | A new proposal. The old one becomes `superseded`, so a record never has more than one pending submission | +| Edits a draft whose create is pending | The draft is updated in place, and the pending submission follows it | +| Edits a draft whose create was rejected | The draft is updated, and a new `create` submission opens: a resubmission | + +A proposal is validated like a normal edit before it is stored, so a moderator never reviews something that could not be saved. + +Some people skip the queue. A moderator holding `can_approve` in the container publishes and edits their own posts immediately, and so does a root role. A moderator editing through `can_edit` always applies the edit at once, even to someone else's record. Those permissions are [scoped to containers](/docs/dev/moderation/scoped-permissions#container-scoped-permissions). + +AdminCP staff work outside this flow: their edits apply directly, as always. + +## What the author sees + +Every public write response carries `approval`. It is `null` when nothing waits, and otherwise describes the submission: + +```json title="PATCH /api/@vitnode/forum/content/topics/42 → 200" +{ + "approval": { + "id": 12, + "kind": "edit", + "reason": null, + "status": "pending", + "submittedAt": "2026-10-13T10:02:11.004Z", + "values": { "title": "The editor forgets my draft on Safari", "body": { "type": "doc", "content": [] } } + }, + "id": 42, + "slug": "the-editor-forgets-my-draft", + "status": "published", + "publishedAt": "2026-10-13T09:40:02.118Z", + "version": 3, + "values": { "title": "The editor forgets my draft", "body": { "type": "doc", "content": [] } } +} +``` + +For a proposal, `values` is still the live record and `approval.values` holds what the author asked for, so an edit page can show both. + + | null' }, + }} +/> + +`GET /{id}/edit` returns the record's latest submission when it is pending, or rejected and not replaced yet, so the author's edit page can say "waiting for a moderator" or show the reason. The shape is `zodContentApprovalState` from `@vitnode/core/content`. + +To list everything an author sent, call the core moderation module. It returns pending, approved and rejected submissions, newest first, for the signed-in member: + +```ts title="plugins/forum/src/pages/my-submissions-data.ts" +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +export const fetchMySubmissions = async (page: number) => { + const res = await fetcher({ + plugin: "@vitnode/core", + method: "get", + module: "moderation", + path: "/approvals/mine", + args: { query: { page } }, + }); + if (!res.ok) throw new Error("Could not load your submissions."); + + return await res.json(); +}; +``` + +Each item has `kind`, `status`, `title`, `contentTypeId`, `itemId`, the container, `reason` and the dates. A guest gets `401`. + +## What a decision does + +A moderator approves or rejects from the ModCP or through the [approval routes](/docs/dev/moderation/reference#approval-routes). + +- **Approving a create** publishes the draft, which emits the content type's `published` event once. A record a moderator hid stays hidden. +- **Approving an edit** applies the proposal as an ordinary update against the version it was made from. That emits one `updated` event and, on an [editorial](/docs/content-engine/editorial) content type, writes one revision. +- **Rejecting** changes nothing on the record. A new topic stays a draft, a published one keeps its live version, and the optional reason is stored for the author. + +Each decision locks the submission first, so two moderators clicking at once get one success. It then locks the record and resolves its container again from the row, so a topic moved to another forum since it was submitted is checked against where it lives now. If the decision cannot go through, the route answers with one of these codes: + +| Code | Status | What happened | +| ----------- | ------ | ------------------------------------------------------------------------------------------------- | +| `decided` | `409` | The submission is no longer pending. Someone else decided first, or the author replaced it | +| `stale` | `409` | The record changed after the proposal was made, or the proposal no longer validates. Reject it, and the author can resubmit | +| `gone` | `409` | The record was deleted. The submission is marked `cancelled` | +| `forbidden` | `403` | The record now lives in a container where this moderator has no `can_approve` | +| `notFound` | `404` | No such submission, or one outside the moderator's scope | + +Deleting a record cancels its pending submission, from the AdminCP or anywhere else. If staff publish a pending draft from the AdminCP first, approving it later only closes the submission. Changing a container's policy never touches submissions that are already waiting. + +## Events + +Opening a submission emits `content.approval.submitted`, and a decision emits `content.approval.approved` or `content.approval.rejected`. They carry the submission id, the content type, the record id, the container, the author and, for decisions, the reviewer. The record's own events are not duplicated: a pending create emits `created` but not `published` until it is approved. Payloads are in [Built-in events](/docs/dev/events/built-in-events#content-approval-events). + +A listener on `content.approval.rejected` is a good place to tell the author why their post did not make it, gently. diff --git a/apps/web/content/docs/content-engine/container-hierarchy.mdx b/apps/web/content/docs/content-engine/container-hierarchy.mdx index aa2cc12ba1..74af6928b0 100644 --- a/apps/web/content/docs/content-engine/container-hierarchy.mdx +++ b/apps/web/content/docs/content-engine/container-hierarchy.mdx @@ -164,7 +164,7 @@ export const boardMovedListener = buildEventListener({ }); ``` -`changedFields` names `name`, `slug` and the metadata fields whose value actually changed. A reorder among siblings is a `moved` event with the same `fromParentId` and `parentId`. Register listeners as described in [Events](/docs/dev/events). [Built-in events](/docs/dev/events/built-in-events#content-container-events) has the field-by-field payloads. +`changedFields` names `name`, `slug` and the metadata fields whose value actually changed, or `policies` when the container's [publishing policies](/docs/content-engine/container-policies) were saved. A reorder among siblings is a `moved` event with the same `fromParentId` and `parentId`. Register listeners as described in [Events](/docs/dev/events). [Built-in events](/docs/dev/events/built-in-events#content-container-events) has the field-by-field payloads. Every write also expires the [page cache](/docs/content-engine/caching) entries that read the collection, including pages that list content filtered by one of its containers, so a renamed board shows its new name without a manual purge. diff --git a/apps/web/content/docs/content-engine/container-policies-api.mdx b/apps/web/content/docs/content-engine/container-policies-api.mdx new file mode 100644 index 0000000000..2ca27af3b0 --- /dev/null +++ b/apps/web/content/docs/content-engine/container-policies-api.mdx @@ -0,0 +1,161 @@ +--- +title: Publishing policies reference +description: Reference for Content Engine container publishing policies - the defineContentContainer policies option, the five policy keys and their fallbacks, the resolution order, the GET and PUT policies routes, the policy report, errors and server helpers. +icon: FileJson +--- + +Exact names, values and behavior of container publishing policies. For a guided setup, follow [Publishing policies](/docs/content-engine/container-policies). Examples use the Forum plugin's `forum.forums` collection. + +## `policies` option + +`defineContentContainer({ policies })` from `@vitnode/core/content`: + +```ts title="plugins/forum/src/content/forums.ts" +policies: { + defaults: { publish: "immediate", edits: "immediate", authorEdit: "always" }, + contentTypes: { + "forum.comment": { authorEdit: "window", authorEditMinutes: 30 }, + }, +}, +``` + +| Name | Type | Required | Default | Description | +| -------------- | -------------------------------------- | -------- | ------- | ------------------------------------------------------------------- | +| `defaults` | policy | No | `{}` | Applies to every content type filed in the collection | +| `contentTypes` | `Record` | No | `{}` | Per content type, such as `"forum.comment"`. Wins over `defaults` | + +Omit `policies`, or set it to `false`, and the collection does not take part: its content types use `publicWrite.create.status`, owners edit for as long as `update.own` allows, `can_edit` stays unscoped and no `can_approve` exists. `policies: {}` turns the feature on with every key on its fallback. + +## Policy keys + +A policy is a partial object of these keys. Each one is resolved on its own. + +| Key | Values | Fallback | Description | +| ------------------- | --------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `create` | `"members"` \| `"moderators"` | `"members"` | Who may create. `"moderators"` means `can_edit` holders in the container | +| `publish` | `"immediate"` \| `"approve"` | From `publicWrite.create.status`: `"published"` → `"immediate"`, `"draft"` → `"approve"` | Whether a new record goes live or waits as a draft with a submission | +| `edits` | `"immediate"` \| `"approve"` | `"immediate"` | Whether an author's edit of a published record applies or becomes a proposal | +| `authorEdit` | `"always"` \| `"never"` \| `"window"` | `"always"` | How long the owner may edit | +| `authorEditMinutes` | Whole number from 1 to 525600 (a year) | `60` | The window for `"window"`, counted from the record's server `createdAt` | + +The constants are exported from `@vitnode/core/content`: `CONTENT_CONTAINER_POLICY_KEYS`, `CONTENT_CONTAINER_POLICY_CREATE`, `CONTENT_CONTAINER_POLICY_MODES`, `CONTENT_CONTAINER_POLICY_AUTHOR_EDIT` and `CONTENT_CONTAINER_POLICY_MAX_MINUTES`. + +## Resolution order + +For each key, the first layer that sets it wins: + +1. the record's container's override for the content type; +2. the nearest ancestor's override, walking `parentId`; +3. `policies.contentTypes[contentTypeId]`; +4. `policies.defaults`; +5. the fallback in the table above. + +A missing key inherits. An explicit value, including `"never"` or a value equal to the parent's, is an override and stops the walk. The engine never stores `undefined`, and empty per-type objects are dropped on save. + +The record's container is always resolved on the server: from its `field.container()`, or for a `field.relation({ container: true })`, from the related record's container. A comment follows its topic, whatever the payload claims. + +## Who passes which rule + +| Check | Passes | +| ------------------------- | ----------------------------------------------------------------------------------------- | +| 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 | + +Container scopes are explained in [Container-scoped permissions](/docs/dev/moderation/scoped-permissions#container-scoped-permissions). + +## Refusals on the public write routes + +Both answer `403` with `"code": "CONTENT_FORBIDDEN"` and one issue whose `field` is `null`: + +| Issue code | Constant | When | +| -------------------- | ------------------------------------------------- | -------------------------------------------------------------------------- | +| `edit_window_closed` | `CONTENT_PUBLIC_WRITE_ISSUE_CODES.editWindowClosed` | The owner edits a public record after `authorEdit` stopped allowing it | +| `moderators_only` | `CONTENT_PUBLIC_WRITE_ISSUE_CODES.moderatorsOnly` | A create in a container with `create: "moderators"` by a non-moderator | + +```json title="PATCH /api/@vitnode/forum/content/comments/18 → 403" +{ + "code": "CONTENT_FORBIDDEN", + "contentTypeId": "forum.comment", + "issues": [ + { "code": "edit_window_closed", "field": null, "message": "Public write denied: forbidden." } + ] +} +``` + +An owner editing a record the public cannot read, such as a hidden one, still gets `404` with no issue, so the answer never confirms it exists. A submission opened by `publish` or `edits` is described in [Approvals](/docs/content-engine/approvals). + +## AdminCP routes + +Mounted only for a collection with policies, in the admin module, under `/api/{pluginId}/admin/content/{admin.permissionModule}`. For the forum that is `/api/@vitnode/forum/admin/content/forum_forums`. + +| Method and path | Admin permission | Body | Success | +| --------------------- | ---------------- | --------------- | -------------------------- | +| `GET /{id}/policies` | `can_view` | | `200` with the report | +| `PUT /{id}/policies` | `can_edit` | `{ overrides }` | `200` with the new report | + +`PUT` replaces the container's whole overrides object. `overrides` maps a content type id to a policy, and only content types filed in the collection with `publicWrite` are accepted. A saved change expires the collection's page cache and emits `content.container.updated` with `changedFields: ["policies"]`. Saving the same overrides again changes nothing and emits nothing. + +```json title="PUT /api/@vitnode/forum/admin/content/forum_forums/3/policies" +{ "overrides": { "forum.topic": { "publish": "approve" } } } +``` + +### Policy report + +| Name | Type | Description | +| ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------- | +| `id` | `number` | The container | +| `overrides` | `Record` | What this container sets itself | +| `contentTypes[].contentTypeId` | `string` | A content type filed in the collection with `publicWrite` | +| `contentTypes[].pluginId` | `string` | Its plugin | +| `contentTypes[].effective` | full policy | What applies in this container | +| `contentTypes[].inherited` | full policy | What would apply without this container's own overrides | +| `contentTypes[].sources` | `Record` | Where each effective value comes from | + +A source is `{ kind: "container", containerId }`, `{ kind: "contentType" }`, `{ kind: "default" }` or `{ kind: "fallback" }`. + +### Errors + +Errors answer `{ code, message }`: + +| Status | `code` | When | +| ------ | --------------- | ------------------------------------------------------------------------------------- | +| `400` | `invalidInput` | The body breaks the schema: an unknown key, a value outside the enum, minutes out of range | +| `400` | `invalidPolicy` | `overrides` names a content type that is not filed in the collection | +| `403` | | Missing the admin permission | +| `404` | `notFound` | No such container in this collection | + +## Definition errors + +`defineContentContainer` throws a `ContentEngineError` when the module loads: + +| Cause | Message starts with | +| --------------------------------------------- | --------------------------------------------------------------------------- | +| `policies` is not an object or `false` | ``policies is an object with `defaults` and `contentTypes`, or `false`.`` | +| An unknown key beside `defaults` and `contentTypes` | `policies.x is not an option.` | +| A `contentTypes` key that is not `plugin.entity` | `policies.contentTypes has the key "x", which is not a content type id` | +| An unknown policy key | `policies.defaults.x is not a policy.` | +| A value outside the allowed ones | `policies.defaults.publish is not valid:` | + +## Server helpers + +From `@vitnode/core/content` (browser-safe): + +| Function | Returns | Behavior | +| --------------------------------------------------------------- | ---------------------------------------- | -------------------------------------------------------------- | +| `resolveContentContainerPolicy({ chain, contentTypeId, fallback, policies })` | `{ policy, sources }` | The pure resolution above. `chain` is the container, then its ancestors, nearest first | +| `contentContainerPolicyFallback(definition)` | `ContentContainerPolicy` | The fallback for one content type | +| `contentAuthorMayEdit(policy, createdAt, now?)` | `boolean` | Whether the owner may still edit | + +From `@vitnode/core/content/server`: + +| Function | Returns | Behavior | +| ------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- | +| `resolveContentRowContainer(c, db, definition, row)` | `ContentRecordContainer \| null` | The container an existing row is filed in, with its ancestor chain | +| `resolveContentWriteContainer(c, db, definition, values)` | `ContentRecordContainer \| null` | The container a new record would be filed in | +| `effectiveContentPolicy(definition, container)` | `EffectiveContentContainerPolicy \| null` | `null` when the collection has no policies | +| `resolveContentPublicationPolicy({ actor, c, container, definition, operation: "create", pluginId })` | `{ approval, policy, status }` | How a member's new record is published. Throws `moderators_only` when creating is not allowed | +| `canModerateContent(c, { container, definition, permission, pluginId })` | `Promise` | Checks a moderator permission against the container's scope | + +The container service from `createContentContainerService` has `policies(id)` and `updatePolicies(id, overrides)`, the same calls the routes make. diff --git a/apps/web/content/docs/content-engine/container-policies.mdx b/apps/web/content/docs/content-engine/container-policies.mdx new file mode 100644 index 0000000000..3ead926f64 --- /dev/null +++ b/apps/web/content/docs/content-engine/container-policies.mdx @@ -0,0 +1,159 @@ +--- +title: Publishing policies +description: Set per-container publishing rules in the VitNode Content Engine. Decide who may post in a forum, whether new posts and edits wait for approval, and how long authors may edit, with defineContentContainer policies and the AdminCP Publishing sheet. +icon: ShieldCheck +--- + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + +Publishing policies let each container decide how members post in it: who may start, whether new records go live or wait for a moderator, whether edits to live records wait too, and how long authors may edit. One forum can be a free-for-all while the one next to it reviews every post. + +This guide uses the Forum plugin: the `forum.forums` container collection, with topics (`forum.topic`) filed into forums and comments (`forum.comment`) living in their topic's forum. + +## Before you begin + +You need a [container collection](/docs/content-engine/containers) and at least one content type with [public write](/docs/content-engine/public-write) filed into it, through `field.container()` or a [`field.relation({ container: true })`](/docs/content-engine/fields#relation). Policies only govern the public write routes. Staff working in the AdminCP are not affected. + + + + + +### Turn on policies for the collection + +Add `policies` to the collection's definition. `defaults` applies to every content type in it, and `contentTypes` overrides it for one content type: + +```ts title="plugins/forum/src/content/forums.ts" +import { defineContentContainer, field } from "@vitnode/core/content"; + +export const forumContainers = defineContentContainer({ + id: "forum.forums", + fields: { + description: field.textarea({ maxLength: 500, nullable: true }), + }, + hierarchy: { maxDepth: 4 }, + // [!code ++:10] + policies: { + defaults: { + publish: "immediate", + edits: "immediate", + authorEdit: "always", + }, + contentTypes: { + "forum.comment": { authorEdit: "window", authorEditMinutes: 30 }, + }, + }, + publicApi: { path: "forums", fields: ["description"] }, + admin: { path: "forum/forums" }, +}); +``` + +Topics go live straight away and stay editable by their authors. Comments go live too, but their authors can only edit them for 30 minutes. + +`policies: {}` is enough to switch the feature on and leave every decision to the AdminCP. Without `policies`, the collection does not take part: its content types keep using `publicWrite.create.status`, owners edit forever, and `can_edit` stays unscoped. + + + + + +### Build and restart + +Policies are stored in a column core already has, so there is no migration. Rebuild the plugin and restart `pnpm dev`: + + + +```bash tab="bun" +bun run build:plugins +``` + +```bash tab="pnpm" +pnpm build:plugins +``` + +```bash tab="npm" +npm run build:plugins +``` + + + +Turning policies on also gives each content type in the collection container-scoped `can_edit` and `can_approve` moderator permissions. Grant and label them as described in [Container-scoped permissions](/docs/dev/moderation/scoped-permissions#container-scoped-permissions). + + + + + +### Override one container in the AdminCP + +Open the collection's screen, such as `/admin/containers/forum/forums`. In a forum's row menu, choose **Publishing**. The **Publishing rules** sheet has one section per content type, with these settings: + +| Setting | Key | Choices | +| ---------------------------- | ------------------- | ----------------------------------------------------------- | +| **Who can start** | `create` | **Members**, **Moderators only** | +| **New posts** | `publish` | **Publish immediately**, **Wait for approval** | +| **Edits to published posts** | `edits` | **Apply immediately**, **Wait for approval** | +| **Author editing** | `authorEdit` | **Always**, **Never**, **For a limited time** | +| **Editing window** | `authorEditMinutes` | Minutes, used by **For a limited time** | + +{/* Image prompt: AdminCP Publishing rules sheet for the forum "Help", with a Topics section where New posts is set to Wait for approval and the other settings show "Inherit (currently: ...)" with a line under each saying where the value comes from, and a Save rules button in the footer */} + +Every setting starts on **Inherit (currently: …)**, and the line below it says where the current value comes from: a parent forum, the collection's defaults or the content type's own default. For the "Help" forum, set **New posts** to **Wait for approval** for topics and choose **Save rules**. + +The sheet needs the collection's `can_view` admin permission to open and `can_edit` to save. Saving emits `content.container.updated` with `changedFields: ["policies"]`. + + + + + +## How a value is chosen + +Each of the five keys is decided on its own. The engine takes the first layer that sets it: + +1. the record's container, for that content type; +2. the nearest ancestor that sets it, walking up the parents; +3. `policies.contentTypes[contentTypeId]` in the definition; +4. `policies.defaults` in the definition; +5. the fallback: `create: "members"`, `publish` from `publicWrite.create.status` (`"published"` becomes `"immediate"`, `"draft"` becomes `"approve"`), `edits: "immediate"`, `authorEdit: "always"`, `authorEditMinutes: 60`. + +With "Help" set to **Wait for approval** for topics, a topic in its child forum "Bug reports" waits for approval too, while comments there still publish immediately: rules are per content type, and nobody changed the comment rules. + +A key that is absent inherits. An explicit value is an override, even when it matches the parent today. If someone later relaxes "Help", a child that explicitly says **Wait for approval** keeps waiting. Choose **Inherit** to remove an override. + +## What members notice + +| Rule | What happens on the public write routes | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `publish: "approve"` | A new record is stored as a draft, a submission opens, and the response carries `approval`. See [Approvals](/docs/content-engine/approvals) | +| `edits: "approve"` | An author's edit of a published record becomes a proposal. The live record does not change until a moderator approves | +| `authorEdit: "never"` | Only moderators edit. The author gets `403` with the `edit_window_closed` issue | +| `authorEdit: "window"` | The author edits until `authorEditMinutes` after the record's server `createdAt`, then gets `403` with `edit_window_closed` | +| `create: "moderators"` | Only moderators holding `can_edit` in that container may create. Everyone else gets `403` with the `moderators_only` issue | + +Moderators are never held back by these rules. A `can_edit` holder edits any record in the container at any age, and a `can_approve` holder skips approval for their own posts. Root roles pass every check. The issue codes come in the usual [rejection](/docs/content-engine/public-write-api#rejection) body, so translate them like any other issue. + +Changing a policy never touches submissions that are already pending. + +## Check the result + +Sign in as a regular member and create a topic in "Help". The response is a draft waiting for approval: + +```json title="POST /api/@vitnode/forum/content/topics → 201" +{ + "approval": { + "id": 7, + "kind": "create", + "reason": null, + "status": "pending", + "submittedAt": "2026-10-13T09:12:44.512Z", + "values": null + }, + "id": 42, + "slug": "the-editor-forgets-my-draft", + "status": "draft", + "publishedAt": null, + "version": 1, + "values": { "title": "The editor forgets my draft", "body": { "type": "doc", "content": [] }, "forum": 3 } +} +``` + +The topic is not on the public site yet. It appears in the [ModCP approval queue](/docs/dev/moderation/review-approvals) for moderators who hold `can_approve` in "Help". A topic in a forum without overrides still answers with `"status": "published"` and `"approval": null`. + +Every option, route and error is in the [Publishing policies reference](/docs/content-engine/container-policies-api). diff --git a/apps/web/content/docs/content-engine/containers.mdx b/apps/web/content/docs/content-engine/containers.mdx index 4dfc156d2c..20fdfb1b04 100644 --- a/apps/web/content/docs/content-engine/containers.mdx +++ b/apps/web/content/docs/content-engine/containers.mdx @@ -73,6 +73,7 @@ Every container already has `id`, `parentId`, `name`, `slug`, `position` and `de | `ordering` | `{ mode?: "manual" \| "alphabetical" }` | No | `{ mode: "manual" }` | How siblings are sorted. See [Hierarchy and ordering](/docs/content-engine/container-hierarchy) | | `publicApi` | `{ path: string; fields?: string[] }` | No | off | Read-only public routes at `/api/{pluginId}/content/{path}` | | `admin` | `{ path?; permissionModule?; navigation? }` | No | see below | The AdminCP screen and its staff permissions | +| `policies` | `{ defaults?; contentTypes? }` \| `false` | No | off | Per-container publishing rules. See [Publishing policies](/docs/content-engine/container-policies) | `admin.path` defaults to the id with dots turned into slashes (`example/boards`), so the screen opens at `/admin/containers/example/boards`. `admin.permissionModule` defaults to the snake-cased id (`example_boards`), and `admin.navigation: false` hides the sidebar entry. @@ -115,6 +116,8 @@ export const discussionContentType = defineContentType({ The column is an `integer` foreign key to `core_content_containers.id` with `ON DELETE RESTRICT`, plus an index on `(board, publishedAt)`. Every write checks that the id belongs to `example.boards`, not just that it exists. All options are in [Fields](/docs/content-engine/fields#container). +A content type can also live in the container of a record it points at, such as a reply in its topic's board, with a required `field.relation({ target, container: true })` instead of a `field.container()`. See [Threaded replies](/docs/content-engine/threads). + @@ -326,12 +329,8 @@ Items come in tree order: each parent, then its subtree. `metadata` holds only ` ## What's next - - Per-container policies are planned, not shipped. Today every container behaves - the same way. - - -Containers are where per-place rules will live: a publishing mode per board (go live or wait as a draft), moderator approval, who may post or read in a board, and boards hidden from guests. The groundwork is in place. A public create already resolves the container on the server and hands it to `resolveContentPublicationPolicy`, so these policies can arrive without changing your routes or forms. - - [Hierarchy and ordering](/docs/content-engine/container-hierarchy): depth, moves, safe deletion and the server-side service. - [Containers on the frontend](/docs/content-engine/container-frontend): a board tree, a board page and a create form with a board picker. +- [Publishing policies](/docs/content-engine/container-policies): who may post in each board, whether posts wait for [approval](/docs/content-engine/approvals), and how long authors may edit. + +Read permissions per container, such as boards hidden from guests, are not available yet. Today every reader sees every container in the public tree. diff --git a/apps/web/content/docs/content-engine/events.mdx b/apps/web/content/docs/content-engine/events.mdx index 90565db250..7d9402bee5 100644 --- a/apps/web/content/docs/content-engine/events.mdx +++ b/apps/web/content/docs/content-engine/events.mdx @@ -47,6 +47,8 @@ A few payload details that change what a listener does: [Container collections](/docs/content-engine/containers) emit their own four events, `content.container.created`, `updated`, `moved` and `deleted`, listed in [Container hierarchy](/docs/content-engine/container-hierarchy#events). +A record waiting for [approval](/docs/content-engine/approvals) emits its `published` or `updated` only when a moderator approves it, once. The submission itself emits `content.approval.submitted`, `approved` and `rejected`, listed in [Built-in events](/docs/dev/events/built-in-events#content-approval-events). + The listener also receives an envelope with the actor, the time and `pluginId`. `pluginId` is always the plugin that owns the content type, even when a core queue task ran the change. Field-by-field descriptions are in [Built-in events](/docs/dev/events/built-in-events#content-engine-events). ## Subscribe to an event diff --git a/apps/web/content/docs/content-engine/fields.mdx b/apps/web/content/docs/content-engine/fields.mdx index 7eace13be0..04903960b8 100644 --- a/apps/web/content/docs/content-engine/fields.mdx +++ b/apps/web/content/docs/content-engine/fields.mdx @@ -167,12 +167,15 @@ slug: field.slug({ source: "title" }), | Name | Type | Required | Default | Description | | ------------- | -------------------- | -------- | ------- | ---------------------------------------------------------- | | `source` | name of a text field | No | | Fills the slug from that field when a create sends no slug | +| `generated` | `boolean` | No | `false` | Mints a random token when a create sends no slug. Not with `source` | | `maxLength` | `number` | No | `160` | `varchar` length and the point where long slugs are cut | | `localized` | `boolean` | No | `false` | One slug per language | | `description` | `string` | No | | Shown under the input | A slug has no `required` or `nullable` option. It is required when there is no `source`, and it is never empty. Every value is normalized, so `"Weekend Pancakes!"` becomes `weekend-pancakes`. Slugs are unique across the table, or within each language when localized. The slug is derived only on create, so renaming the title later keeps the old URL. `source` must name a `field.text()`, and a localized slug needs a localized source. +For records without a natural title, such as forum comments, use `field.slug({ generated: true })`. The server mints a random token of lowercase letters and digits, 10 characters or `maxLength` if shorter, such as `k3v9q2m7xa`. A generated slug cannot be `localized` or have a `source`, and it is never [publicly writable](/docs/content-engine/public-write-api#writable-fields). See [Threaded replies](/docs/content-engine/threads). + ## User ```ts @@ -187,7 +190,7 @@ authorId: field.user({ multiple: true, ordered: true, min: 1 }), | `min` | `number` | No | | Fewest people. Needs `multiple` | | `onDelete` | `"cascade"` \| `"restrict"` \| `"set null"` | No | see below | What deleting the user does | -A single user field is nullable unless you set `required: true`. `onDelete` defaults to `"set null"` for a nullable single user, `"restrict"` for a required one and `"cascade"` for `multiple`. User fields cannot be listed in `publicApi.fields`. +A single user field is nullable unless you set `required: true`. `onDelete` defaults to `"set null"` for a nullable single user, `"restrict"` for a required one and `"cascade"` for `multiple`. User fields cannot be listed in `publicApi.fields`. To publish an author's name and avatar, name a single, shared user field in [`publicApi.people`](/docs/content-engine/reference#publicapi) instead. ## File @@ -228,8 +231,11 @@ category: field.relation({ required: true, onDelete: "restrict", target: () => c | `ordered` | `boolean` | No | `false` | Keeps the editor's order. Needs `multiple` | | `min` | `number` | No | | Fewest targets, 1 to 500. Needs `multiple` | | `onDelete` | `"restrict"` \| `"cascade"` \| `"set null"` | No | `"restrict"` | What deleting the target does. `"set null"` needs a nullable single relation | +| `container` | `boolean` | No | `false` | The record lives in the target record's [container](#container). Required, to-one, never `self` | + +A single relation must be `required` or `nullable`. A to-many relation can be neither. A to-one `self: true` relation, such as the reply a reply answers, must be `nullable`, because the first record has nothing to point at. -A single relation must be `required` or `nullable`. A to-many relation can be neither. The column has the type of the target's key: `integer`, `uuid` or `bigint`, depending on its [`idStrategy`](/docs/content-engine/id-strategy). [Relations](/docs/content-engine/relations) shows the model wiring and the junction tables. +`container: true` files the record in the container of the record it points at, such as a comment in its topic's forum. The server always reads that container from the related row, so a client cannot claim another one, and [publishing policies](/docs/content-engine/container-policies) and container-scoped moderators apply as if the record had its own `field.container()`. A content type takes its container from one field only: one `field.container()` or one `container: true` relation. The target must have a `field.container()`, or the API stops at boot with ``Field "x" is `container: true`, but its target ... is not filed in a container.`` The column has the type of the target's key: `integer`, `uuid` or `bigint`, depending on its [`idStrategy`](/docs/content-engine/id-strategy). [Relations](/docs/content-engine/relations) shows the model wiring and the junction tables. ## Container diff --git a/apps/web/content/docs/content-engine/index.mdx b/apps/web/content/docs/content-engine/index.mdx index 963484194b..9940778a31 100644 --- a/apps/web/content/docs/content-engine/index.mdx +++ b/apps/web/content/docs/content-engine/index.mdx @@ -71,6 +71,9 @@ Content types live in code and in your migrations, not in the database. There is | Link records, group fields or repeat rows | [Relations](/docs/content-engine/relations) | | Organize records into boards, sections or categories | [Containers](/docs/content-engine/containers) | | Show a container tree and its content on the site | [Containers on the frontend](/docs/content-engine/container-frontend) | +| Decide who posts in each container and what needs review | [Publishing policies](/docs/content-engine/container-policies) | +| Let moderators approve new posts and edits first | [Approvals](/docs/content-engine/approvals) | +| Build nested replies under a topic or a post | [Threaded replies](/docs/content-engine/threads) | | Translate content into several languages | [Translations](/docs/content-engine/localization) | | Store and render formatted text | [Rich text](/docs/content-engine/rich-text) | | Keep revisions, share previews or schedule publishing | [Editorial](/docs/content-engine/editorial) | @@ -90,3 +93,4 @@ Content types live in code and in your migrations, not in the database. There is - [Fields](/docs/content-engine/fields): every `field.*` helper with its options, column type and form control. - [Plugin files](/docs/content-engine/plugin-files): the files and exports the app and the API read from your plugin. - [Live editing API](/docs/content-engine/live-editing-api): the HTTP API and deployment requirements behind live editing. +- [Publishing policies reference](/docs/content-engine/container-policies-api): policy keys, resolution order, routes and errors. diff --git a/apps/web/content/docs/content-engine/meta.json b/apps/web/content/docs/content-engine/meta.json index ee083c8d49..ea2190734e 100644 --- a/apps/web/content/docs/content-engine/meta.json +++ b/apps/web/content/docs/content-engine/meta.json @@ -17,6 +17,7 @@ "caching", "---Model content---", "relations", + "threads", "localization", "rich-text", "id-strategy", @@ -24,6 +25,8 @@ "containers", "container-hierarchy", "container-frontend", + "container-policies", + "approvals", "---Editorial---", "editorial", "live-editing", @@ -41,6 +44,7 @@ "fields", "plugin-files", "public-write-api", + "container-policies-api", "live-editing-api" ] } diff --git a/apps/web/content/docs/content-engine/public-api.mdx b/apps/web/content/docs/content-engine/public-api.mdx index e933f76a2e..1a834fe7fb 100644 --- a/apps/web/content/docs/content-engine/public-api.mdx +++ b/apps/web/content/docs/content-engine/public-api.mdx @@ -250,6 +250,7 @@ All routes are `GET` and live under `/api/{pluginId}/content/{publicApi.path}`. | `/delivery/resolve/{slug}` | Whether a URL is a record, a redirect or nothing, only with [delivery](/docs/content-engine/public-pages) | | `/delivery/item/{id}` | Canonical URL and SEO metadata for one record, only with delivery | | `/delivery/sitemap` | Sitemap entries, only with `delivery.sitemap` | +| `/thread?root={id}` | One page of a reply tree under a root record, only with [`thread`](/docs/content-engine/threads) | The list accepts `first` or `last` (at most 50, also the default), `cursor`, `page`, `search` over `searchableFields`, `orderBy`, `order` and one parameter per filterable field. A filterable [container field](/docs/content-engine/fields#container) also accepts `includeDescendants=true`, so `?board=4&includeDescendants=true` lists board 4 and every board below it; an unknown container answers `404`. See [Containers on the frontend](/docs/content-engine/container-frontend#add-the-board-page). A record is public only when its `status` is `published`, its `publishedAt` is set and not later than the database clock, and it is not [hidden](/docs/content-engine/content-visibility). Every `publicApi` option is in the [content type reference](/docs/content-engine/reference#publicapi). 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 9e63bd67a7..e4e12f906d 100644 --- a/apps/web/content/docs/content-engine/public-write-api.mdx +++ b/apps/web/content/docs/content-engine/public-write-api.mdx @@ -23,11 +23,11 @@ publicWrite: { | `ownership.field` | field name | Yes | | A single, shared `field.user()`. The server stamps it with the signed-in user on create | | `create` | object | No | off | Adds `POST /`. At least one of `create` and `update` is required | | `create.fields` | field names | Yes | | Non-empty allowlist of [writable fields](#writable-fields). Must include every field a new record needs | -| `create.status` | `"draft"` \| `"published"` | No | `"draft"` | What a new record starts as. The server decides; the payload never does | +| `create.status` | `"draft"` \| `"published"` | No | `"draft"` | What a new record starts as. The server decides; the payload never does. [Container policies](/docs/content-engine/container-policies) take over when the container collection has them | | `create.captcha` | `boolean` | No | `false` | Requires a [captcha](/docs/dev/captcha) token on every create, when a captcha provider is configured | | `update` | object | No | off | Adds `GET /{id}/edit` and `PATCH /{id}`, and registers the `can_edit` moderator permission | | `update.fields` | field names | Yes | | Non-empty allowlist of writable fields | -| `update.own` | `boolean` | No | `true` | Lets the owner edit their own record while it is not hidden. With `false`, only moderators edit | +| `update.own` | `boolean` | No | `true` | Lets the owner edit their own record while it is not hidden and the container's `authorEdit` allows it. With `false`, only moderators edit | The content type also needs: @@ -63,6 +63,8 @@ A `container` is writable in `create.fields` only. A member picks the container A content type with `update` adds `can_edit` to its plugin's moderator permissions, under the content type's permission module, such as `discussion`. It shares the `@vitnode/example:discussion:can_edit` label with the admin permission. If the plugin declares that module under `permissionStaff.moderator` itself, the declared list wins. +When the content type lives in a container collection with [publishing policies](/docs/content-engine/container-policies), `can_edit` is scoped by that collection and `can_approve` is added next to it. See [Container-scoped permissions](/docs/dev/moderation/scoped-permissions#container-scoped-permissions). + ## Generated routes The routes live in the public module, under `/api/{pluginId}/content/{publicApi.path}`, next to the [public read routes](/docs/content-engine/public-api#public-api-routes). Only the ones `publicWrite` enables are mounted, and the [fetcher](/docs/dev/fetcher) types them the same way: a `post` to a content type without `create` is a compile error. @@ -108,10 +110,12 @@ Every successful response is the same record shape: | `status` | `"draft"` \| `"published"` | | | `publishedAt` | ISO date string \| `null` | | | `version` | `number` | Editorial content types only. Send it back as `expectedVersion` | +| `approval` | object \| `null` | The record's pending submission, or a rejection nothing replaced yet. See [Approvals](/docs/content-engine/approvals#what-the-author-sees) | | `values` | object | Every field in `create.fields` or `update.fields`, `null` when empty. Nothing else | ```json title="200" { + "approval": null, "id": 1, "slug": "how-do-i-add-a-widget-zone-below-an-article", "status": "published", @@ -143,7 +147,7 @@ Every successful response is the same record shape: | `400` | Rejection, `CONTENT_INVALID_INPUT` | The body is not JSON, breaks a rule, names a field outside the allowlist, or links to a record that is not available | | `400` | Plain text | `create.captcha` is on and the captcha token is missing or invalid | | `401` | Rejection, `CONTENT_UNAUTHENTICATED` | No user session | -| `403` | Rejection, `CONTENT_FORBIDDEN` | The record is publicly readable, but you are not its owner or a moderator | +| `403` | Rejection, `CONTENT_FORBIDDEN` | The record is publicly readable, but you are not its owner or a moderator, the author's edit window closed, or the container lets only moderators create | | `404` | Rejection, `CONTENT_NOT_FOUND` | The record does not exist, or it is a draft or hidden and you may not edit it | | `409` | Conflict | A unique value is taken, or the record changed since `expectedVersion` | | `429` | `{ error, retryAfter }` | The [rate limit](#rate-limit) is used up | @@ -166,7 +170,7 @@ type ContentPublicWriteRejection = { }; ``` -`issues` is empty for `401`, `403` and `404`. For `400`, each issue names the field it belongs to, or `null` for the request as a whole. Translate an issue by its `code`; `message` is English text for developers. +`issues` is empty for `401` and `404`, and for `403` unless a [container policy](/docs/content-engine/container-policies-api#refusals-on-the-public-write-routes) refused the write. For `400`, each issue names the field it belongs to, or `null` for the request as a whole. Translate an issue by its `code`; `message` is English text for developers. A title that is too short and an owner the client tried to set: @@ -279,6 +283,8 @@ Issue codes: | `not_writable` | `CONTENT_PUBLIC_WRITE_ISSUE_CODES.notWritable` | A key in `values` outside the allowlist. One issue per key | | `unavailable` | `CONTENT_PUBLIC_WRITE_ISSUE_CODES.unavailable` | A relation names a record that is missing, a draft or hidden | | `invalid_json` | `CONTENT_PUBLIC_WRITE_ISSUE_CODES.invalidJson` | The body is not JSON | +| `edit_window_closed` | `CONTENT_PUBLIC_WRITE_ISSUE_CODES.editWindowClosed` | On `403`: the container's `authorEdit` no longer lets the owner edit | +| `moderators_only` | `CONTENT_PUBLIC_WRITE_ISSUE_CODES.moderatorsOnly` | On `403`: the container has `create: "moderators"` and you hold no `can_edit` there | | `too_small`, `too_big`, `invalid_type`, `invalid_value` | Zod | A value breaks the field's rules, such as `minLength` or an enum | | `custom` | Zod | A custom rule, such as an empty required rich text document or an update with no fields | | `unrecognized_keys` | Zod | A key beside `values`, such as `expectedVersion` on a content type without `editorial` | @@ -307,15 +313,15 @@ createContentPublicWriteService({ c, model, pluginId }); | Method | Returns | Behavior | | ------------------ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `create(values)` | `Promise` | Validates `values` against `create.fields`, stamps the owner, checks relation targets, creates and, when the policy says so, publishes in one transaction. Emits events and syncs search after the commit | +| `create(values)` | `Promise` | Validates `values` against `create.fields`, stamps the owner, checks relation targets, creates and, when the policy says so, publishes or opens an approval submission in one transaction. Emits events and syncs search after the commit | | `findEditable(id)` | `Promise` | Reads one record for its owner or a moderator, drafts included | -| `update(id, body)` | `Promise` | Validates `{ values, expectedVersion }`, locks the row, authorizes, checks relation targets and writes in one transaction. Emits events and syncs search after the commit | +| `update(id, body)` | `Promise` | Validates `{ values, expectedVersion }`, locks the row, authorizes, checks relation targets and writes, or stores a proposal when the container reviews edits, in one transaction. Emits events and syncs search after the commit | The methods throw instead of answering: | Error | When | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `ContentPublicWriteDenied` | `reason` is `"unauthenticated"`, `"forbidden"` or `"notFound"`. Also `"notFound"` when `create` or `update` is off | +| `ContentPublicWriteDenied` | `reason` is `"unauthenticated"`, `"forbidden"` or `"notFound"`. Also `"notFound"` when `create` or `update` is off. `issue` is `"edit_window_closed"`, `"moderators_only"` or `null` | | `ContentPublicWriteInputError` | A relation target is not available. Carries `field` and `detail` | | `ZodError` | `values` or the body breaks the allowlist or a field rule | | `ContentVersionConflict` | `expectedVersion` is out of date (editorial content types) | @@ -411,9 +417,9 @@ A route outside `/content/` only gets the global rate limit, which is why this o | Function | Returns | Behavior | | ------------------------------------------------------------------------------ | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `resolveContentPublicWriteActor(c)` | `{ type: "api", userId }` \| `null` | The signed-in user of the public session. Never an AdminCP session, never the body | -| `authorizeContentPublicWrite(c, { action, actor, definition, pluginId, row })` | `{ allowed, via: "owner" \| "moderator" \| null }` | The owner and moderator rules above, for a stored `row`. `action` is `"update"` | +| `authorizeContentPublicWrite(c, { action, actor, container?, definition, pluginId, policy?, row })` | `{ allowed, via, reason? }` | The owner and moderator rules above, for a stored `row`. `action` is `"update"`. With a `policy`, a closed edit window gives `reason: "editWindowClosed"` | | `contentPublicWriteRefusal(row)` | `"forbidden"` \| `"notFound"` | `"forbidden"` only when the public can already read `row` | -| `resolveContentPublicationPolicy({ actor, container, definition, operation })` | `"draft"` \| `"published"` | The status a public create starts with. Today it returns `publicWrite.create.status`. `container` is the resolved container, or `null` | +| `resolveContentPublicationPolicy({ actor, c, container, definition, operation, pluginId })` | `{ approval, policy, status }` | How a public create starts. Without container policies, `status` is `publicWrite.create.status`. With them, see [Publishing policies reference](/docs/content-engine/container-policies-api#server-helpers) | ## Definition errors diff --git a/apps/web/content/docs/content-engine/public-write.mdx b/apps/web/content/docs/content-engine/public-write.mdx index f81a7eb76c..14241e4d64 100644 --- a/apps/web/content/docs/content-engine/public-write.mdx +++ b/apps/web/content/docs/content-engine/public-write.mdx @@ -255,7 +255,7 @@ This is why `ownership` is required and not configurable: if a client could choo Creating needs a signed-in user and nothing else. Editing is allowed for two kinds of people: - **The owner**, while `update.own` is `true` (the default) and the record is not [hidden](/docs/content-engine/content-visibility). A record a moderator hid is the moderator's to restore, not the author's to keep editing. Set `update.own: false` to let only moderators edit. -- **Moderators** who hold the content type's `can_edit` moderator permission, directly or through a role. +- **Moderators** who hold the content type's `can_edit` moderator permission, directly or through a role. In a container collection with [publishing policies](/docs/content-engine/container-policies), `can_edit` is [scoped to containers](/docs/dev/moderation/scoped-permissions#container-scoped-permissions), and the policy can also limit how long the owner may edit. To make someone a discussion moderator: @@ -296,7 +296,7 @@ export const vitNodeApiConfig = buildApiConfig({ Public writes trigger the same side effects as AdminCP writes, so nothing new needs wiring: -- [Events](/docs/content-engine/events): `created` on create, then `published` when `create.status` is `"published"`, and `updated` with `changedFields` on an edit that changed something. There are no separate public-write events. +- [Events](/docs/content-engine/events): `created` on create, then `published` when the record goes live, and `updated` with `changedFields` on an edit that changed something. There are no separate public-write events. A record waiting for [approval](/docs/content-engine/approvals) emits `published` or `updated` when a moderator approves it, plus the `content.approval.*` events. - [Search](/docs/content-engine/search) is synced after the transaction commits, so a discussion published on create is searchable straight away. - On an [editorial](/docs/content-engine/editorial#revisions) content type, each create and edit stores a revision attributed to the signed-in user, and staff can restore it from the AdminCP. @@ -304,13 +304,8 @@ Public writes trigger the same side effects as AdminCP writes, so nothing new ne A content type organized into [containers](/docs/content-engine/containers), such as discussions in forum boards, lets members pick the container when they create a record: list the `field.container()` in `create.fields`. It cannot be in `update.fields`, so moving a record to another container stays a staff edit. The server resolves the container inside the write's transaction and refuses a missing or foreign one with a `400` on that field. -### Future container policies +### Container policies - - Per-container publication policies are planned, not shipped. Today every - public create uses `publicWrite.create.status`. - - -Whether a new record starts as a draft or goes live is decided in one server-side function, `resolveContentPublicationPolicy`, and never by the payload. It already receives the signed-in user, the operation and the resolved container, so each container will be able to choose its own policy, such as "publish immediately" in one board and "wait for a moderator's approval" in another, without changing the routes or your forms. +Whether a new record starts as a draft or goes live is decided on the server, never by the payload. Without container policies, it is `create.status`. When the record's container collection has [publishing policies](/docs/content-engine/container-policies), the container decides instead: who may create, whether new records and edits wait for a moderator, and how long the owner may edit. A new record that waits answers with `status: "draft"`, and every response carries an `approval` object, described in [Approvals](/docs/content-engine/approvals). Next, build the create and edit pages in [Public write frontend](/docs/content-engine/public-write-frontend). diff --git a/apps/web/content/docs/content-engine/reference.mdx b/apps/web/content/docs/content-engine/reference.mdx index a3f89a1c2b..a3c179378b 100644 --- a/apps/web/content/docs/content-engine/reference.mdx +++ b/apps/web/content/docs/content-engine/reference.mdx @@ -68,6 +68,7 @@ Many of these rules are type errors too, so your editor often complains before t | `visibility` | `boolean` | No | off | [Hide and unhide](#visibility) records. Needs `publication` | | `duplication` | `true` \| [`duplication`](#duplication) | No | off | A [Duplicate](#duplication) action that copies a record into a new draft | | `reports` | `true` \| [`reports`](#reports) | No | off | Readers can [report](#reports) published records to moderators. Needs `publicApi` | +| `thread` | [`thread`](#thread) | No | off | Replies that answer each other under one root record. Needs `publicApi` | | `indexes` | [`indexes`](#indexes) | No | `[]` | Extra database indexes | A feature block is on when it is present. `publicApi`, `publicWrite`, `localization` and `search` take an options object. `publication`, `liveEditing` and `visibility` are `true`, and `editorial`, `delivery` and `duplication` take `true` or an options object. Leave a block out, or set it to `false`, to keep it off. Write `true` or the object directly rather than a `boolean` variable, because the generated types read the literal to decide which columns and routes exist. @@ -152,6 +153,7 @@ Generates read-only routes at `/api/{pluginId}/content/{path}` that return publi | `orderableFields` | field names | No | `[]` | Accepted by `?orderBy=`. `publishedAt` is always added | | `defaultOrderBy` | field name | No | `"publishedAt"` | Must be orderable | | `defaultOrder` | `"asc"` \| `"desc"` | No | `"desc"` | | +| `people` | user field names | No | `[]` | Single, shared `field.user()` fields returned as a public person summary. Not listed in `fields` | What `fields` accepts: @@ -160,6 +162,8 @@ What `fields` accepts: - Group and repeatable leaves one at a time, as `"seo.title"`. Naming the whole group throws, so a leaf added later stays private. - Not `status` (every public row is published), and not `field.user()`, so an author is never published by accident. +`people` is the explicit opt-in for a user. Each named field comes back under its own name as `{ id, name, nameCode, avatarColor, avatarUrl, role: { color, prefix } }`, or `null` when nobody is set or the account was deleted. The summaries for a whole page are loaded in one query. `people` refuses a field that is not a `field.user()`, a `multiple: true` or localized user field, and a name listed twice. + `searchableFields`, `filterableFields` and `orderableFields` must all be in `fields`. Repeatable leaves fit none of them. `orderableFields` also refuses file fields, to-many relations and localized fields. ```ts title="plugins/example/src/content/article.ts" @@ -265,12 +269,13 @@ Keeps published records in the [site search index](/docs/dev/search). Drafts nev | Name | Type | Required | Description | | ------------------ | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `titleField` | field name | Yes | The result heading. A text field that is not nullable | +| `titleField` | field name | Yes | The result heading. A text field that is not nullable, or a to-one, non-nullable relation to another searchable content type that lends its title. [Guide](/docs/content-engine/search#borrow-the-title-from-a-related-record) | | `contentFields` | field names | Yes | The indexed body, at least one, no duplicates. Text, textarea, richText or slug. Repeatable leaves such as `"faq.answer"` are allowed | | `pathTemplate` | `string` | Yes | The result URL. Starts with `/`, contains `{slug}` exactly once, at most 512 characters | | `descriptionField` | field name | No | Shown in result excerpts. Text, textarea or richText | | `authorField` | field name | No | A top-level `field.user()` credited in the index. It does not have to be public | | `imageField` | field name | No | A shared single `field.file()` shown as the result image. Must be in `publicApi.fields` | +| `context` | `{ field, pathTemplate? }` | No | What the record belongs to, for feed sentences. A `field.container()` with a `pathTemplate`, or a to-one relation to a searchable content type. Defaults to the `titleField` relation. [Guide](/docs/content-engine/search#say-where-a-record-lives) | Every field except `authorField` must be in `publicApi.fields`, or a result snippet would leak a private value. Leave the language out of `pathTemplate`, because your [i18n settings](/docs/dev/i18n) add the locale prefix. @@ -302,6 +307,33 @@ publicApi: { path: "blog", fields: ["id", "title", "friendlyUrl", "content"] }, reports: true, ``` +## `thread` + +Makes records of one content type answer each other under a root record, like forum comments under a topic. It adds the public `GET /{path}/thread` route and checks every placement on the server. Guide: [Threaded replies](/docs/content-engine/threads). + +```ts title="plugins/forum/src/content/comment.ts" +thread: { parent: "parent", root: "topic", maxDepth: 6 }, +``` + +| Name | Type | Required | Default | Description | +| ---------- | ---------- | -------- | ------- | ----------------------------------------------------------------------------- | +| `parent` | field name | Yes | | A to-one, nullable `field.relation({ self: true })`: the record being answered | +| `root` | field name | Yes | | A required, to-one `field.relation({ target })` to another content type | +| `maxDepth` | `number` | No | `8` | Deepest level a record may sit at, from 1 (a flat list) to 32 | + +Both fields must be in `publicApi.fields` and neither may be in `publicWrite.update.fields`. `thread` is not available on a localized content type yet. + +| Cause | Message starts with | +| ------------------------------------------- | --------------------------------------------------------------------------- | +| `parent` is not a to-one self relation | `thread.parent "x" must be a to-one` | +| `parent` is not nullable | `thread.parent "x" must be nullable` | +| `root` is not a required to-one relation | `thread.root "x" must be a required, to-one` | +| `maxDepth` out of range | `thread.maxDepth must be a whole number from 1 to 32.` | +| No `publicApi` | ``thread needs `publicApi`: the thread route`` | +| `parent` or `root` not in `publicApi.fields` | `thread uses "x", so list it in` | +| `parent` or `root` in `update.fields` | `publicWrite.update.fields includes "x".` | +| A localized content type | `thread is not available on a localized content type yet` | + ## `duplication` Adds a **Duplicate** action and the `POST /{id}/duplicate` staff route, which copy a record and its translations into a new draft. Pass `true`, or an object to change the title suffix. Guide: [Duplicate content](/docs/content-engine/duplicate-content). @@ -393,6 +425,8 @@ Each row is a rule `defineContentType` enforces, with the start of the message i | `admin.list.defaultOrderBy` | A system column or an `admin.list.orderableFields` entry | `admin.list.defaultOrderBy is "x", which is not in admin.list.orderableFields.` | | `publicApi.defaultOrderBy` | An orderable field | `publicApi.defaultOrderBy is "x", which is not in publicApi.orderableFields.` | | `publicApi.searchableFields`, `filterableFields`, `orderableFields` | The field in `publicApi.fields` | `publicApi.searchableFields includes "x", which is not in publicApi.fields.` | +| `publicApi.people` | A single, shared `field.user()` | `publicApi.people includes "x", which is a "text" field.` | +| `thread` | `publicApi`, with `parent` and `root` in its fields | ``thread needs `publicApi`: the thread route`` | ### Unique across content types diff --git a/apps/web/content/docs/content-engine/search.mdx b/apps/web/content/docs/content-engine/search.mdx index 7bb1a3624b..69519f7467 100644 --- a/apps/web/content/docs/content-engine/search.mdx +++ b/apps/web/content/docs/content-engine/search.mdx @@ -50,7 +50,7 @@ export const articleContentType = defineContentType({ }); ``` -- `titleField` is the result heading. It must be a `field.text()` that is not nullable. +- `titleField` is the result heading. It must be a `field.text()` that is not nullable, or a relation that lends the related record's title (see [Borrow the title from a related record](#borrow-the-title-from-a-related-record)). - `descriptionField` is optional and leads the result excerpt. Text, textarea or rich text. - `contentFields` is the searchable body. It takes at least one field and no duplicates. Text, textarea, rich text or slug fields, plus repeatable leaves such as `"faq.answer"`. Rich text is indexed as its words, never as JSON. The title always ranks higher than the body, so listing it here as well only repeats it in the excerpt. - `pathTemplate` is the result URL. It starts with `/` and contains `{slug}` exactly once. Leave the language out, because your i18n settings add it (see [Localized content types](#localized-content-types)). @@ -128,6 +128,63 @@ Adding `authorField` or `imageField` to content that already exists needs a **Re In the API, each result carries `authors` (id, name, `nameCode`, avatar) in order, `authorIds`, and `image` with `url`, `width` and `height`, or `null`. +## Borrow the title from a related record + +Some records have no title of their own. A forum comment is one: on `/search` it should be headed by the topic it belongs to. Point `titleField` at a relation instead of a text field: + +```ts title="plugins/forum/src/content/comment.ts" +search: { + titleField: "topic", // [!code highlight] + contentFields: ["body"], + authorField: "author", + pathTemplate: "/forum/comments/{slug}", +}, +``` + +The result is headed by the related record's own `search.titleField`, here the topic's title. The relation has a few rules: + +- 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. + +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.` + +The two records then stay in step: + +- A document is public only while both the record and the related record are. A comment under a draft or hidden topic stays out of `/search`, `/discover` and public timelines. +- The document stores the related record as its container: `containerType` is the target's content type id and `containerId` its record id. +- When the related record's search title changes, or it is published, unpublished, hidden or unhidden, every record that borrows its title is re-indexed. Deleting it removes their documents. + +Records saved before you turned this on need a **Reindex** of the collection, or **Rebuild index** in **AdminCP → Advanced → Search**. + +## 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: + +```ts title="plugins/forum/src/content/topic.ts" +search: { + titleField: "title", + contentFields: ["body"], + authorField: "author", + pathTemplate: "/forum/topics/{slug}", + context: { field: "forum", pathTemplate: "/forum/{slug}" }, // [!code highlight] +}, +``` + +`field` is one of two things: + +- A `field.container()`. The context is the container's name, linked through `pathTemplate`, because a container has no public URL of its own. `{slug}` is the container's slug. +- A to-one `field.relation()` to another searchable content type. The context is the related record's search title, linked to its own search URL, so leave `pathTemplate` out. When `titleField` already [borrows from a relation](#borrow-the-title-from-a-related-record), that relation is the context automatically, which is why forum comments need no `context` line. + +The document stores the context's title and URL in its `metadata` (`{ context: { title, url } }`) and the record it points at as `containerType` / `containerId`. Renaming a container, or changing its slug, re-indexes every record in it. + +The feed turns that into a sentence for the types it knows, with links to the author, the record and its context, and the record's text underneath. Types without a sentence keep the plain heading-and-excerpt layout. + +{/* Image prompt: The VitNode Discover feed in light theme. Two timeline cards. The first reads "aXen commented on Tabs or spaces · 18 minutes ago" in small grey text with the names in dark medium weight, then "Searchable comment about tabs versus spaces" as body text. The second reads "aXen posted Tabs or spaces in General · 1 hour ago", then the topic excerpt. Small square icons on a vertical line to the left. 1200x500. */} + +Like the title, the context is filled in on the next write or reindex. + ## What gets indexed and when Every record is in the index, but only public ones show up on `/search`. A draft is stored as a private document without a URL. A [hidden](/docs/content-engine/content-visibility) record's document turns private the moment it is hidden, and public again when it is unhidden. With `authorField`, it appears on its author's own timeline and on the AdminCP user page, but never in public results. diff --git a/apps/web/content/docs/content-engine/threads.mdx b/apps/web/content/docs/content-engine/threads.mdx new file mode 100644 index 0000000000..17dae170f3 --- /dev/null +++ b/apps/web/content/docs/content-engine/threads.mdx @@ -0,0 +1,233 @@ +--- +title: Threaded replies +description: Build nested replies with the VitNode Content Engine thread option - a to-one self relation for the parent, a root relation that also gives the reply its container, generated slugs, public author summaries and the GET /thread route. +icon: MessagesSquare +--- + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + +The `thread` option turns a content type into replies that answer each other under one root record, like comments under a forum topic or under a blog post. The server keeps the tree valid, and one public route returns a page of it, nested and in order. + +This guide builds `forum.comment` from the Forum plugin. Each comment belongs to a `forum.topic`, may answer another comment, and lives in its topic's forum. + +## Before you begin + +You need the root content type with a [public API](/docs/content-engine/public-api), here `forum.topic`. For members to post comments, read [Public write](/docs/content-engine/public-write) first. + + + + + +### Define the comment + +Create the content type. Four fields do the threading work, and `thread` names two of them: + +```ts title="plugins/forum/src/content/comment.ts" +import { defineContentType, field } from "@vitnode/core/content"; + +import { topicContentType } from "@/content/topic"; + +export const commentContentType = defineContentType({ + id: "forum.comment", + tableName: "forum_comments", + + fields: { + slug: field.slug({ generated: true }), + body: field.richText({ required: true }), + author: field.user(), + topic: field.relation({ + target: () => topicContentType, + required: true, + container: true, + onDelete: "cascade", + }), + parent: field.relation({ self: true, nullable: true, onDelete: "set null" }), + }, + + publication: true, + visibility: true, + + publicApi: { + path: "comments", + fields: ["id", "slug", "body", "topic", "parent", "publishedAt", "createdAt"], + filterableFields: ["topic"], + people: ["author"], + }, + + publicWrite: { + ownership: { field: "author" }, + create: { fields: ["body", "topic", "parent"], status: "published" }, + update: { fields: ["body"] }, + }, + + thread: { parent: "parent", root: "topic", maxDepth: 6 }, +}); +``` + +- `parent` is a to-one **self relation**: the comment being answered, or `null` for a direct answer to the topic. It must be nullable, because the first comment has nothing to point at. +- `topic` is the **root**. `container: true` files the comment in the topic's forum, so [publishing policies](/docs/content-engine/container-policies) and container-scoped moderators apply to comments without a container field of their own. A `container: true` relation must be required and to-one. +- `slug` with `generated: true` gets a random 10-character token on create, such as `k3v9q2m7xa`, because a comment has no title to derive one from. +- `people: ["author"]` publishes the author as a small profile summary. `publicApi.fields` never exposes a user, so this is a separate opt-in. + +`parent` and `topic` must be in `publicApi.fields`, so readers can rebuild the tree, and they cannot be in `update.fields`: a comment is placed when it is written and stays there. Every option and its errors are in the [`thread` reference](/docs/content-engine/reference#thread). + + + + + +### Create the model + +A self relation finds its own table, so `references` lists only the topic: + +```ts title="plugins/forum/src/database/comments.ts" +import { createContentModel } from "@vitnode/core/content/server"; + +import { commentContentType } from "@/content/comment"; + +import { forum_topics } from "./topics"; + +export const commentContent = createContentModel(commentContentType, { + references: { topic: () => forum_topics.id }, +}); + +export const forum_comments = commentContent.table; +``` + +An entry for `parent` in `references` throws, which is the engine politely saying it has this one covered. + + + + + +### Register, build and migrate + +Add `commentContent` to `contentTypes` of `buildContentPublicModule` (and the admin module, for an AdminCP screen) next to the topics. Then run this from the workspace root and restart `pnpm dev`: + + + +```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 table gets a `parent` column with a foreign key to `forum_comments.id` and a `topic` column pointing at `forum_topics.id`. The Forum plugin also adds `indexes: [{ on: ["topic", "parent", "createdAt"] }]` to the definition, which matches how the thread route reads. See [`indexes`](/docs/content-engine/reference#indexes). + + + + + +### Read the thread + +`GET /{path}/thread` returns one page of top-level replies, oldest first, each followed by every public answer below it. Call it with the [fetcher](/docs/dev/fetcher): + +```ts title="plugins/forum/src/pages/thread-data.ts" +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { CONFIG_PLUGIN } from "@/const"; + +export const fetchTopicThread = async (topicId: number, after?: string) => { + const res = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/comments", + path: "/thread", + args: { query: { root: String(topicId), first: 20, after } }, + }); + if (!res.ok) throw new Error("Could not load the comments."); + + return await res.json(); +}; +``` + +`items` is already flattened depth first, so render it as a list and indent each item by its `depth`. Pass `pageInfo.endCursor` as `after` to load the next page. + + + + + +## What the thread route returns + +| Query | Type | Default | Description | +| ------- | -------- | ------- | --------------------------------------------------- | +| `root` | `string` | | The root record's id. Required | +| `first` | `number` | `20` | Top-level replies per page, from 1 to 50 | +| `after` | `string` | | `pageInfo.endCursor` of the previous page | + +```json title="GET /api/@vitnode/forum/content/comments/thread?root=42 (trimmed)" +{ + "items": [ + { + "depth": 1, + "id": 17, + "parentId": null, + "removed": false, + "record": { + "id": 17, + "slug": "k3v9q2m7xa", + "body": { "type": "doc", "content": [] }, + "topic": 42, + "parent": null, + "publishedAt": "2026-10-13T09:20:00.000Z", + "createdAt": "2026-10-13T09:20:00.000Z", + "author": { + "id": 3, + "name": "Ada", + "nameCode": "ada", + "avatarColor": "#2563eb", + "avatarUrl": null, + "role": { "color": null, "prefix": null } + } + } + }, + { "depth": 2, "id": 18, "parentId": 17, "removed": true, "record": null }, + { "depth": 3, "id": 21, "parentId": 18, "removed": false, "record": { "id": 21, "parent": 18 } } + ], + "pageInfo": { "endCursor": "WyIyMDI2LTEwLTEzVDA5OjIwOjAwLjAwMFoiLDE3XQ", "hasNextPage": false, "totalCount": 1 }, + "replyCount": 2, + "truncated": false +} +``` + +- **Tombstones.** A reply that is not public, such as a hidden one, is left out. If a public answer sits below it, it comes back as `removed: true` with `record: null`, so the tree keeps its shape. Reply 18 above is one. +- **Counts.** `totalCount` counts public top-level replies, and `replyCount` counts public replies at any depth. +- **Limits.** One page carries at most 500 nodes and stops at `maxDepth`. `truncated: true` means some nested replies did not fit. +- **The root must be public.** A missing, draft or hidden topic answers `404`. + +`record` has the same shape as a record from the public list, including `people` summaries. A person is `null` when the account was deleted. + +## Placement rules + +Every create, and every update that touches `parent` or `topic`, is checked on the server, in the AdminCP too. A broken rule answers `400` with an issue on the field: + +| Rule | Message | +| ----------------------------------------------------------- | ------------------------------------------------------------------------- | +| The parent exists | `The reply you are answering does not exist.` | +| The parent is in the same topic | `The reply you are answering belongs to another conversation.` | +| A reply never answers itself or one of its answers | `A reply cannot answer itself or one of its answers.` | +| Nothing sits deeper than `maxDepth` | `Replies nest at most 6 levels deep. Answer a reply higher up.` | +| A reply with answers keeps its topic | `This reply has answers. Move the whole conversation instead of one reply.` | + +On the public routes, the parent must also be public: a hidden or draft parent gets the usual `unavailable` issue. + +Deleting a reply with `onDelete: "set null"` turns its answers into top-level replies. Hiding it with [visibility](/docs/content-engine/content-visibility) keeps them where they are, under a tombstone, which is usually what a forum wants. + +## Check the result + +Create a topic, then post a comment and an answer to it as a signed-in member: + +```bash +curl -X POST http://localhost:3000/api/@vitnode/forum/content/comments \ + -H "Content-Type: application/json" -b "vitnode_auth=" \ + -d '{"values":{"topic":42,"parent":17,"body":{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Same here!"}]}]}}}' +``` + +The response has a generated `slug`. `GET /api/@vitnode/forum/content/comments/thread?root=42` now lists comment 17 with the new answer below it at `depth: 2`. Sending a `parent` from another topic answers `400` with an issue on `parent`. diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index 17c5eeaf78..3f7f7ec3d4 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -1152,7 +1152,7 @@ nothing. | Event | Payload | Fires when | | --------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------ | | `content.container.created` | `{ definitionId, pluginId, containerId, parentId }` | A container is created | -| `content.container.updated` | `{ definitionId, pluginId, containerId, parentId, changedFields }` | Its name, slug or a metadata field actually changed | +| `content.container.updated` | `{ definitionId, pluginId, containerId, parentId, changedFields }` | Its name, slug, a metadata field or its publishing policies actually changed | | `content.container.moved` | `{ definitionId, pluginId, containerId, parentId, fromParentId, position }` | It moved to another parent, another position, or both | | `content.container.deleted` | `{ definitionId, pluginId, containerId, parentId }` | An empty container is deleted | @@ -1194,7 +1194,7 @@ or a delete do not get events of their own. }, changedFields: { description: - '`updated` only. `name`, `slug` and the metadata fields whose value changed.', + '`updated` only. `name`, `slug` and the metadata fields whose value changed, or `["policies"]` when the container\'s [publishing policies](/docs/content-engine/container-policies) were saved.', type: 'string[]', }, }} @@ -1218,6 +1218,100 @@ export const boardDeletedListener = buildEventListener({ +## Content approval events + +Three names, declared in `VitNodeEvents` in +`packages/vitnode/src/api/models/events.ts` and shared by every content type +that waits for [approval](/docs/content-engine/approvals) under a container's +[publishing policy](/docs/content-engine/container-policies). Each fires +**after** its transaction commits. A refused decision, such as a `409 stale`, +emits nothing. + +| Event | Payload | Fires when | +| ---------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `content.approval.submitted` | `{ submissionId, replacedSubmissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId }` | A member's create or edit starts waiting, including a resubmission or a newer proposal | +| `content.approval.approved` | `{ submissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId, reviewerId }` | A moderator approves a submission | +| `content.approval.rejected` | `{ submissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId, reviewerId, reason }` | A moderator rejects a submission | + +The record's own events are never duplicated. A pending create emits the +content type's `created` when it is stored, and its `published` only when it is +approved. Approving an edit emits one `updated`, with the changed fields. +Rejecting emits nothing from the content type, because nothing on the record +changed. + + + + +Emitted by the public write service when a submission opens, and by +`decideContentSubmission` when a moderator decides, so the ModCP and your own +code that calls it emit the same events. Updating a draft whose create is +already pending refreshes that submission and emits no approval event. + + + +**A listener would** notify the author when their topic is approved or +rejected, or ping a moderators' channel when the queue grows: + +```ts title="Example: tell the author about a rejection" +export const approvalRejectedListener = buildEventListener({ + event: 'content.approval.rejected', + name: 'notify-rejected-author', + handler: async (c, payload) => { + if (payload.authorId === null) return + + await c.get('log').info( + `Submission ${payload.submissionId} by user ${payload.authorId} was rejected`, + ) + }, +}) +``` + + + + ## Blog events (`@vitnode/blog`) diff --git a/apps/web/content/docs/dev/moderation/index.mdx b/apps/web/content/docs/dev/moderation/index.mdx index f701b79810..01b2c1b213 100644 --- a/apps/web/content/docs/dev/moderation/index.mdx +++ b/apps/web/content/docs/dev/moderation/index.mdx @@ -9,7 +9,7 @@ VitNode moderation lets signed-in users report content, and lets moderators revi | Part | What it does | | -------------------- | -------------------------------------------------------------------------------------------------------------- | | Reports | A report stores the reason, the reporter's details and a server-side snapshot of what the reporter saw | -| Moderator CP | `/modcp/reports` on the public site: a reports board, the snapshot, assignment, notes, resolution and content actions | +| Moderator CP | `/modcp/reports` on the public site: a reports board, the snapshot, assignment, notes, resolution and content actions. `/modcp/approvals` holds content waiting for approval | | Scoped permissions | A moderator permission can cover every resource of a family, such as every forum section, or only some of them | A moderator signs in with their normal user session. The ModCP never asks for an AdminCP session, and the server authorizes every ModCP request. @@ -22,6 +22,7 @@ A moderator signs in with their normal user session. The ModCP never asks for an | Limit moderators to some categories or sections | [Scoped permissions](/docs/dev/moderation/scoped-permissions) | | Give a user or role moderator permissions in the AdminCP | [Configure moderators](/docs/dev/moderation/configure-moderators) | | Review, assign, resolve and act on reports | [Process reports](/docs/dev/moderation/process-reports) | +| Approve or reject new content and edits before they go live | [Review approvals](/docs/dev/moderation/review-approvals) | | Look up routes, options, events and limits | [Moderation reference](/docs/dev/moderation/reference) | ## How the parts fit together @@ -35,4 +36,4 @@ Reports belong to core, not to the plugin. They keep their snapshot after the ar ## What is not included yet -This first version covers reports plus hiding and restoring content. It has no warnings, bans, approval queues, automatic moderation, anonymous reports, report notifications or permanent deletion from the ModCP. Moderators still have to read things themselves, sorry. +This version covers reports, hiding and restoring content, and [reviewing approvals](/docs/dev/moderation/review-approvals). It has no warnings, bans, automatic moderation, anonymous reports, report notifications or permanent deletion from the ModCP. Moderators still have to read things themselves, sorry. diff --git a/apps/web/content/docs/dev/moderation/meta.json b/apps/web/content/docs/dev/moderation/meta.json index a591b75a1a..752f2acbc5 100644 --- a/apps/web/content/docs/dev/moderation/meta.json +++ b/apps/web/content/docs/dev/moderation/meta.json @@ -8,6 +8,7 @@ "scoped-permissions", "configure-moderators", "process-reports", + "review-approvals", "reference" ] } diff --git a/apps/web/content/docs/dev/moderation/reference.mdx b/apps/web/content/docs/dev/moderation/reference.mdx index f22cb421fc..f87cf14b60 100644 --- a/apps/web/content/docs/dev/moderation/reference.mdx +++ b/apps/web/content/docs/dev/moderation/reference.mdx @@ -1,6 +1,6 @@ --- title: Moderation reference -description: Reference for VitNode moderation, covering the reports option, scope families, report targets, permissions, ModCP API routes, statuses, events, snapshot format and limits. +description: Reference for VitNode moderation, covering the reports option, scope families, report targets, permissions, ModCP report and approval routes, statuses, events, snapshot format and limits. icon: BookOpen --- @@ -34,6 +34,8 @@ Added to the content type's `permissionModule` in the **moderator** catalog, unl Content actions also need `can_manage_reports`, because they are recorded in the report. +[Public write](/docs/content-engine/public-write-api#moderator-permission) adds `can_edit` to the same module. In a container collection with [publishing policies](/docs/content-engine/container-policies), `can_edit` and `can_approve` are scoped by the collection's family, whose id is the container definition id and whose resources are container ids and `"{id}:tree"` subtrees. See [Container-scoped permissions](/docs/dev/moderation/scoped-permissions#container-scoped-permissions). Generated entries merge with the report permissions; only a module the plugin declares itself in `permissionStaff.moderator` is left as declared. + ## Scoped grants A stored grant is `{ plugin, module, permission, scope? }`. `scope` is one of: @@ -110,6 +112,33 @@ All under `/api/@vitnode/core/moderation`, using the user session. A guest gets | `POST /reports/{id}/actions/{action}` | `can_manage_reports` and the action's permission | `{ expectedVersion }` | | `GET /reports/{id}/assignees` | `can_manage_reports` | | +## Approval routes + +Also under `/api/@vitnode/core/moderation`, for [approvals](/docs/content-engine/approvals). The queue routes need a moderator entry and show only submissions in containers where the moderator holds `can_approve`; anything else answers `404`, so its existence is not revealed. Every response is `Cache-Control: private, no-store`. + +| Method and path | Permission | Input | Success | +| -------------------------------- | ------------- | ------------------------------------------------------------------------- | ---------------------------------- | +| `GET /approvals` | `can_approve` | Query `status` (default `pending`), `contentTypeId`, `containerId`, `page`, `pageSize` | `{ items, page, pageSize, total }` | +| `GET /approvals/filters` | `can_approve` | | `{ contentTypes, containers }` the moderator may review | +| `GET /approvals/mine` | Signed in | Query `page`, `pageSize` | The member's own pending, approved and rejected submissions, newest first | +| `GET /approvals/{id}` | `can_approve` | | The submission with `values`, `baseValues`, `current`, `fields`, `record` and `history` | +| `POST /approvals/{id}/approve` | `can_approve` | | `{ id, status }` | +| `POST /approvals/{id}/reject` | `can_approve` | `{ reason? }`, up to 2000 characters | `{ id, status }` | + +The pending queue is oldest first; decided statuses are newest decision first. A list item has `id`, `kind`, `status`, `contentTypeId`, `pluginId`, `itemId`, `title`, `author`, `reviewer`, `container` (`{ id, name, slug, path }`), `reason`, `createdAt` and `decidedAt`. + +A refused decision answers `{ error, message }`: + +| `error` | Status | When | +| ----------- | ------ | ----------------------------------------------------------------------- | +| `notFound` | `404` | No such submission in the moderator's scope | +| `forbidden` | `403` | The record now lives in a container where the moderator cannot approve | +| `decided` | `409` | The submission is no longer `pending` | +| `gone` | `409` | The record was deleted. The submission becomes `cancelled` | +| `stale` | `409` | The record changed since the proposal, or the proposal no longer validates | + +Statuses are `pending`, `approved`, `rejected`, `superseded` (replaced by the author's newer proposal) and `cancelled` (the record was deleted first). The constants are `CONTENT_SUBMISSION_STATUSES`, `CONTENT_SUBMISSION_KINDS` and `CONTENT_SUBMISSION_DECISION_CODES` from `@vitnode/core/content`. Server code can decide one with `decideContentSubmission(c, { decision, id, reason?, reviewerId })` from `@vitnode/core/content/server`, which throws `ContentSubmissionDecisionError` with the same `code` and `status`. + ## Events | Event | Payload | @@ -121,6 +150,9 @@ All under `/api/@vitnode/core/moderation`, using the user session. A guest gets | `moderation.report.reopened` | `{ reportId }` | | `moderation.report.note_updated` | `{ reportId, noteId }` | | `moderation.report.note_deleted` | `{ reportId, noteId }` | +| `content.approval.submitted` | `{ submissionId, replacedSubmissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId }` | +| `content.approval.approved` | `{ submissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId, reviewerId }` | +| `content.approval.rejected` | `{ submissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId, reviewerId, reason }` | Adding a note emits no event. Hiding through a report also emits the content type's own `hidden` or `unhidden` event. See [Built-in events](/docs/dev/events/built-in-events#moderation-events). @@ -156,9 +188,14 @@ The snapshot is read in one read-only transaction, so its fields and `source` de | Reports per user | 20 per hour | | Open reports per reporter and target | 1 | | Queue page size | 25 by default, up to 100 | +| Approval queue page size | 25 by default, up to 100 | +| Rejection reason | Optional, up to 2000 characters | +| Pending submissions per record | 1 | File fields keep `{ id, name, mimeType, size, url }` only. The file's bytes are never copied, so a file deleted later cannot be opened from the report. ## Database The migration `20261011193321_add_moderation_reports` adds `core_moderation_reports` and `core_moderation_report_events`. Neither references the reported content, so deleting content keeps its reports. Reporter, assignee and resolver are `ON DELETE SET NULL`, so deleting an account keeps the evidence. The unique `openKey` column enforces one open report per reporter and target, even under concurrent submissions. + +Approvals live in `core_content_submissions`, one row per submission. Rows are never deleted with the content, author and reviewer are `ON DELETE SET NULL`, and a partial unique index allows one `pending` submission per record. Container policy overrides are stored in `core_content_containers.policies`. diff --git a/apps/web/content/docs/dev/moderation/review-approvals.mdx b/apps/web/content/docs/dev/moderation/review-approvals.mdx new file mode 100644 index 0000000000..603890ec62 --- /dev/null +++ b/apps/web/content/docs/dev/moderation/review-approvals.mdx @@ -0,0 +1,65 @@ +--- +title: Review approvals +description: Approve or reject new content and edits waiting in the VitNode Moderator CP approval queue. Filter the queue, compare an edit with the live version and leave the author a reason. +icon: ClipboardCheck +--- + +When a container's [publishing policy](/docs/content-engine/container-policies) asks for approval, new content and edits from members do not go live straight away. They wait in the **Approvals** queue of the Moderator CP at `/modcp/approvals` until a moderator says yes or no. [Approvals](/docs/content-engine/approvals) explains what is stored meanwhile and what authors see. + +## Before you begin + +You need a moderator entry with `can_approve` for the content type, in a scope that covers the container the content lives in: the container itself, its subtree, or **All resources**. The queue only shows submissions you may decide, so two moderators can see two different queues. See [Container-scoped permissions](/docs/dev/moderation/scoped-permissions#container-scoped-permissions) and [Configure moderators](/docs/dev/moderation/configure-moderators). + + + + + +### Open the queue + +Choose **Moderator CP** in the user menu, then **Approvals**. The number next to it counts what is still pending. + +{/* Image prompt: Moderator CP approvals queue with status tabs (Pending, Approved, Rejected, Replaced, Cancelled), Type and Location filters, and a list of submissions showing the author avatar, a New or Edit badge, the content type, the title, the location and the date */} + +Each row shows who sent it, whether it is **New** content or an **Edit** of something already live, the content type (a topic or a comment, for example), where it lives and when it arrived. + +- The tabs switch between **Pending** and the decided ones: **Approved**, **Rejected**, **Replaced** (the author sent a newer version) and **Cancelled** (the content was deleted first). +- **Type** and **Location** narrow the list to one content type or one container. + +Tabs, filters and the page number live in the address, so a filtered queue is a link you can share. + + + + + +### Read the submission + +Open a row. For **New** content you see every proposed field. For an **Edit** you see **Changes**: each changed field shows **Live now** next to **Proposed**, and the untouched fields are folded away below. + +**History** lists earlier submissions for the same content, with their outcomes and reasons. **View live** opens published content in a new tab when its content type has a page address (`search.pathTemplate`). + + + + + +### Decide + +- **Approve** publishes new content, or applies the edit to the live version. On a content type with [revisions](/docs/content-engine/editorial#revisions), the edit is one new revision. +- **Reject** opens a dialog with an optional **Reason**. Nothing on the site changes, and the author can read your reason and try again. + +Either way you land back in the queue with a short confirmation. + + + + + +## When a decision fails + +| Message | What happened | +| --- | --- | +| **Already decided** | Another moderator got there first. | +| **The record changed** | Someone edited the content after the proposal was made. Reject it and ask the author to resubmit. | +| **The content is gone** | It was deleted, so the submission was cancelled. | +| **Out of your reach** | The content moved to a container where you cannot approve. | +| **Submission not found** | It no longer exists, or it is outside what you moderate. | + +The detail page warns you when the content is gone or has changed before you click, so surprises should be rare. Rare, not extinct. diff --git a/apps/web/content/docs/dev/moderation/scoped-permissions.mdx b/apps/web/content/docs/dev/moderation/scoped-permissions.mdx index a046dfdcc1..8a3985b800 100644 --- a/apps/web/content/docs/dev/moderation/scoped-permissions.mdx +++ b/apps/web/content/docs/dev/moderation/scoped-permissions.mdx @@ -1,6 +1,6 @@ --- title: Scoped moderator permissions -description: Limit VitNode moderator permissions to selected resources, such as some forum sections or categories, with defineModerationScope and a content model's moderationScope. +description: Limit VitNode moderator permissions to selected resources, such as some forum sections or categories, with defineModerationScope, a content model's moderationScope, or the container-scoped can_edit and can_approve of a container collection with policies. icon: ListChecks --- @@ -121,6 +121,41 @@ The report permissions of `example.article` are now scoped by `example.category` Rebuild with `pnpm build:plugins` and restart `pnpm dev`. Open **AdminCP → Staff → Moderators**, edit a moderator and select the example plugin. **View reports** and **Claim, assign, annotate and resolve reports** now show a scope under the checkbox, and **Change** opens the category picker. See [Configure moderators](/docs/dev/moderation/configure-moderators). +## Container-scoped permissions + +A [container collection](/docs/content-engine/containers) with [publishing policies](/docs/content-engine/container-policies) brings its own scope family, so there is nothing to define. The family id is the collection's id, such as `forum.forums`, and its label is the collection's title. + +Every content type filed in that collection with [public write](/docs/content-engine/public-write), through `field.container()` or `field.relation({ container: true })`, gets two moderator permissions scoped by it: + +| Permission | Added when | Allows | +| ------------- | -------------------------- | ---------------------------------------------------------------------------------------- | +| `can_edit` | `publicWrite.update` is on | Edit any record in the container from the public site, at any age; create where `create: "moderators"` | +| `can_approve` | `publicWrite` is on | See and decide [approvals](/docs/dev/moderation/review-approvals) there, and skip approval for your own posts | + +They merge with the report permissions when the content type also has `reports: true`. Label them in the plugin's locale file. `can_edit` shares its key with the admin permission, and a missing label shows the raw name: + +```json title="plugins/forum/src/locales/en.json" +{ + "@vitnode/forum:topic:can_edit": "Edit topics", + "@vitnode/forum:topic:can_approve": "Approve topics", + "@vitnode/forum:comment:can_edit": "Edit comments", + "@vitnode/forum:comment:can_approve": "Approve comments" +} +``` + +### Pick forums or whole subtrees + +A resource is either one container or one container with everything below it: + +| Resource | Picker label | Covers | +| ----------- | ------------ | ------------------------------------------------------------------------- | +| `"12"` | Help | Container 12 only, never its children | +| `"12:tree"` | Help › * | Container 12 and every container below it, including ones created later | + +The `:tree` entry is listed only for a container that has children. A record in container `C` is covered by a grant that names `C`, `C:tree` or the `:tree` of any ancestor, by **All resources**, by an unscoped grant, or by a root role. The server resolves the record's container on every check, and again when a decision is made, so a topic moved to another forum follows its new scope. + +A moderator entry on its own grants nothing. Someone flagged as staff, with no `can_edit` or `can_approve` for a forum, edits and approves exactly as much there as any member: nothing beyond their own posts. + ## How grants combine A user's effective moderator permissions come from their own staff entry and the entries of every role they hold: diff --git a/apps/web/content/docs/dev/search.mdx b/apps/web/content/docs/dev/search.mdx index 9d00cd4ef6..1c1161b7e3 100644 --- a/apps/web/content/docs/dev/search.mdx +++ b/apps/web/content/docs/dev/search.mdx @@ -113,6 +113,38 @@ For setup, credentials, configuration, and the first rebuild, follow the --- +## Filter the search page + +`/search` has a filter sidebar next to the results (a **Filters** sheet on phones): + +- **Content** - tick one or more types. Each row shows how many results it would have with the other filters applied. +- **Date created** - any time, the past 24 hours, week, month or year, or a **Custom range** picked on a calendar. +- **Authors** - add as many people as you like. A result matches when any of them wrote it, which counts co-authors too. + +Results sit on a timeline grouped by month and day, so you can tell at a glance what happened yesterday and what is from last spring. + +The same filters work in the public search API: + +| Parameter | Example | What it does | +| ----------- | --------------------------- | ------------------------------------------------ | +| `types` | `forum.topic,forum.comment` | Only these content types | +| `authorIds` | `1,7` | Written by any of these members (up to 20) | +| `from` | `2026-10-01T00:00:00.000Z` | Created at or after this moment | +| `to` | `2026-10-14T23:59:59.999Z` | Created at or before this moment | + +```bash +curl "http://localhost:3000/api/@vitnode/core/search?authorIds=1,7&from=2026-10-01T00:00:00.000Z" +``` + +Two small routes feed the sidebar: + +- `GET /api/@vitnode/core/search/authors?search=ax` finds members credited on public content. Without `search` it returns the most active ones. +- `GET /api/@vitnode/core/search/facets` takes the same filters as the search route (minus `types`) and returns `{ "types": { "forum.comment": 4, ... } }`. + +A search engine plugin that can count implements `countByType` and sets `capabilities.facets` to `true`. One that can't returns `types: null`, and the sidebar quietly drops the numbers. Engines should also honour `authorIds`, or the author filter will be more of a suggestion. + +--- + ## Member timelines Every member gets a timeline: everything credited to them, newest first. It lives on their profile (`/users/{nameCode}`) and on the **Timeline** tab of their AdminCP user page. diff --git a/apps/web/content/docs/guides/forum.mdx b/apps/web/content/docs/guides/forum.mdx new file mode 100644 index 0000000000..89323bd047 --- /dev/null +++ b/apps/web/content/docs/guides/forum.mdx @@ -0,0 +1,154 @@ +--- +title: Run a Community Forum +description: Enable the @vitnode/forum plugin, create forums in AdminCP, choose who publishes straight away, and let members post topics and nested comments. +icon: MessagesSquare +--- + +`@vitnode/forum` adds a community forum to a VitNode app: a tree of forums, +topics inside them and Reddit-style nested comments. It is built only from +Content Engine pieces - a [container](/docs/content-engine/containers) for the +forums, two content types and a [thread](/docs/content-engine/threads) - so +everything you learn here works the same way in your own plugins. + + + The plugin is `private: true` and ships with the VitNode repository, like + `@vitnode/example`. It is not published to npm yet. + + + + + +### Enable the API plugin + +Add the API factory to `vitnode.api.config.ts` - in `apps/web` for a single +app, and in `apps/api` too for a split deployment. + +```ts title="apps/web/src/vitnode.api.config.ts" +import { forumApiPlugin } from "@vitnode/forum/config.api"; // [!code ++] + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [ + forumApiPlugin(), // [!code ++] + ], +}); +``` + + + + +### Enable the web plugin + +The web factory brings the pages, the AdminCP **Forums** screen and the +translations. + +```ts title="apps/web/src/vitnode.config.ts" +import { forumPlugin } from "@vitnode/forum/config"; // [!code ++] + +export const vitNodeConfig = buildConfig({ + plugins: [ + forumPlugin(), // [!code ++] + ], +}); +``` + + + + +### Migrate and start + +The plugin owns two tables, `forum_topics` and `forum_comments`. Run the +migrations, then start the app. + + + +```bash tab="bun" +bun run db:migrate +bun dev +``` + +```bash tab="pnpm" +pnpm db:migrate +pnpm dev +``` + +```bash tab="npm" +npm run db:migrate +npm run dev +``` + + + + + + +### Create your forums + +In AdminCP, open **Forums** and create a few. Forums nest up to four levels +deep, and you can drag them to reorder. A forum's **Icon** accepts the same +`icon:name` or emoji value as role prefixes. + +{/* Image prompt: VitNode AdminCP Forums screen with a nested tree of forums (General, Help with two subforums, Showcase), drag handles, and a "Create forum" button. Light theme, 1440x900. */} + + + + +## Pages members get + +| Page | What it does | +| ------------------------ | ---------------------------------------------------------------------- | +| `/forum` | Every forum with its topic and comment counts and its newest topic | +| `/forum/:slug` | Subforums and the topics of one forum, newest first | +| `/forum/create/topic` | Start a topic (signed-in members) | +| `/forum/topics/:slug` | The topic, its nested comments, and a comment box | +| `/forum/topics/:id/edit` | Edit your own topic | +| `/forum/comments/:slug` | Permalink of one comment - redirects to its place in the topic | +| `/forum/me/pending` | Your posts a moderator approved, declined, or has not looked at yet | + +Comments nest up to six levels - hit **Reply** under any comment to answer it. +On small screens deep comments stop indenting, so the text keeps its width, and +every branch can be collapsed. A comment removed by a moderator stays as "This +comment was removed." when it has public replies, so the conversation keeps its +shape. + +The forum index, forum and topic pages are cached for a minute and refreshed in the +background. Edit buttons and the comment box load in the browser, so every +visitor gets the same cached page. Topic pages set a canonical URL and +`DiscussionForumPosting` structured data with the comment count. + +Topics also have [delivery](/docs/content-engine/seo): a renamed topic +redirects its old address to the new one, and +`/api/@vitnode/forum/content/topics/delivery/sitemap` lists every public topic +for your `sitemap.xml`. Topics waiting for approval never appear there, in +search or in any list - until a moderator says yes, they don't exist for +anyone but their author. + +Topics and comments show up in [search](/docs/dev/search), on `/discover` and +on their authors' profile timelines. A comment is headed by its topic's title +and stays out of public results while its topic is not public - see +[Borrow the title from a related record](/docs/content-engine/search#borrow-the-title-from-a-related-record). +Upgrading a forum that already has posts? Rebuild the index once in +**AdminCP → Advanced → Search**. + +## Choose who publishes straight away + +By default every member's topic and comment goes live immediately and authors can +edit their posts for as long as they like. Change that per forum in the forum's +**Publishing** settings - for example, require approval for new topics in +**Announcements**, or let authors edit comments for one hour only. A forum +inherits from its parent until you override a value. See +[Container policies](/docs/content-engine/container-policies) for every option. + +When a post needs approval, the author sees "Waiting for a moderator", and the +post appears on `/forum/me/pending`. Moderators with the **Approve** permission for +that forum review it in ModCP. See [Approvals](/docs/content-engine/approvals). + +## Grant moderators a forum + +AdminCP only manages the forums themselves. Topics and comments live on the +public forum: authors edit their own, and moderators step in right there or in +ModCP. + +Moderator permissions for topics and comments are scoped by forum. Give a +moderator role **Edit** and **Approve** on **Topics** and **Comments**, and pick +the forums it covers - a single forum, or a forum and everything below it. See +[Scoped permissions](/docs/dev/moderation/scoped-permissions). diff --git a/apps/web/content/docs/guides/meta.json b/apps/web/content/docs/guides/meta.json index 5d02740da6..3450691ddb 100644 --- a/apps/web/content/docs/guides/meta.json +++ b/apps/web/content/docs/guides/meta.json @@ -3,5 +3,5 @@ "description": "End-to-end walkthroughs that build a real feature on VitNode, one ordered step at a time.", "icon": "BookOpenText", "root": true, - "pages": ["index", "first-plugin", "---Plugins by VitNode---", "blog", "..."] + "pages": ["index", "first-plugin", "---Plugins by VitNode---", "blog", "forum", "..."] } diff --git a/apps/web/package.json b/apps/web/package.json index 4615f62685..34f934909e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,13 +22,14 @@ "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.104.1", "@tanstack/react-query-devtools": "^5.104.1", - "@tanstack/react-router": "^1.170.41", + "@tanstack/react-router": "^1.170.44", "@tanstack/react-router-devtools": "^1.167.2", "@tanstack/react-router-ssr-query": "^1.167.3", - "@tanstack/react-start": "^1.168.60", + "@tanstack/react-start": "^1.169.1", "@vitnode/blog": "workspace:*", "@vitnode/core": "workspace:*", "@vitnode/example": "workspace:*", + "@vitnode/forum": "workspace:*", "@vitnode/supabase-storage": "workspace:*", "d3-shape": "^3.2.0", "dotenv": "^18.0.6", @@ -52,7 +53,7 @@ }, "devDependencies": { "@tanstack/devtools-vite": "^0.8.5", - "@tanstack/router-cli": "^1.167.40", + "@tanstack/router-cli": "^1.167.43", "@types/d3-shape": "^3.2.0", "@types/mdx": "^2.0.14", "@types/node": "^26.6.4", diff --git a/apps/web/src/admin-nav.gen.ts b/apps/web/src/admin-nav.gen.ts index 3399647d89..39e211ce02 100644 --- a/apps/web/src/admin-nav.gen.ts +++ b/apps/web/src/admin-nav.gen.ts @@ -19,11 +19,13 @@ import { adminNavBundle } from '@vitnode/core/tanstack/admin' import { adminNav as adminNav0 } from '@vitnode/blog/admin/nav' import { adminNav as adminNav1 } from '@vitnode/example/admin/nav' +import { adminNav as adminNav2 } from '@vitnode/forum/admin/nav' export const pluginAdminNav = [ adminNav0, // @vitnode/blog adminNav1, // @vitnode/example + adminNav2, // @vitnode/forum ] satisfies AdminNavPluginSource[] export const adminNav = adminNavBundle({ plugins: pluginAdminNav }) diff --git a/apps/web/src/api-registry.gen.ts b/apps/web/src/api-registry.gen.ts index 5a9977b0a7..ecd4051cb0 100644 --- a/apps/web/src/api-registry.gen.ts +++ b/apps/web/src/api-registry.gen.ts @@ -21,11 +21,13 @@ import type { VitNodeApiPlugin as ApiPlugin0 } from '@vitnode/blog/config.api' import type { VitNodeApiPlugin as ApiPlugin1 } from '@vitnode/example/config.api' +import type { VitNodeApiPlugin as ApiPlugin2 } from '@vitnode/forum/config.api' declare module '@vitnode/core/lib/fetcher/registry' { interface ApiPluginRegistry { '@vitnode/blog': ApiPlugin0 '@vitnode/example': ApiPlugin1 + '@vitnode/forum': ApiPlugin2 } } diff --git a/apps/web/src/content-modules.gen.ts b/apps/web/src/content-modules.gen.ts index bf8b1912f6..a5152b2c8f 100644 --- a/apps/web/src/content-modules.gen.ts +++ b/apps/web/src/content-modules.gen.ts @@ -13,8 +13,10 @@ import * as contentModule0 from '@vitnode/blog/content' import * as contentModule1 from '@vitnode/example/content' +import * as contentModule2 from '@vitnode/forum/content' export const contentModules: Readonly> = { '@vitnode/blog': contentModule0, '@vitnode/example': contentModule1, + '@vitnode/forum': contentModule2, } diff --git a/apps/web/src/content-registry.gen.ts b/apps/web/src/content-registry.gen.ts index 7e5a9a5aa9..52f663bb9c 100644 --- a/apps/web/src/content-registry.gen.ts +++ b/apps/web/src/content-registry.gen.ts @@ -19,11 +19,13 @@ import { buildContentFrontendRegistry, setContentFrontendRegistry } from '@vitno import { adminContent as adminContent0 } from '@vitnode/blog/admin/content' import { adminContent as adminContent1 } from '@vitnode/example/admin/content' +import { adminContent as adminContent2 } from '@vitnode/forum/admin/content' export const pluginContentTypes = [ adminContent0, // @vitnode/blog adminContent1, // @vitnode/example + adminContent2, // @vitnode/forum ] satisfies ContentFrontendPluginSource[] export const contentRegistry = buildContentFrontendRegistry(pluginContentTypes) diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index a96d7f3da2..916e9ce008 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -2766,13 +2766,53 @@ }, "sortBy": "Sortuj według", "title": "Szukaj", + "activity": { + "commented": "{author} dodaje komentarz w {context}", + "posted_in": "{author} publikuje {item} w {context}", + "commented_self": "Dodaje komentarz w {context}" + }, "types": { "blog_post": "Wpis", + "forum_comment": "Komentarz", + "forum_topic": "Temat", "unknown": "Treść" }, "userTab": { "overview": "Przegląd", "timeline": "Oś czasu" + }, + "searchField": "Szukaj wpisów, tematów i komentarzy…", + "clearSearch": "Wyczyść wyszukiwanie", + "results": "{count, plural, one {# wynik} few {# wyniki} many {# wyników} other {# wyniku}}", + "filters": { + "title": "Filtry", + "active": "{count, plural, one {# aktywny filtr} few {# aktywne filtry} many {# aktywnych filtrów} other {# aktywnego filtra}}", + "clearAll": "Wyczyść wszystko", + "reset": "Resetuj", + "content": "Treść", + "date": "Data utworzenia", + "authors": "Autorzy", + "anyAuthor": "Dowolny autor", + "addAuthor": "Dodaj", + "findAuthor": "Znajdź autora", + "noAuthors": "Brak autorów pasujących do „{term}”.", + "loadingAuthors": "Szukanie autorów…", + "removeAuthor": "Usuń {name}", + "pickDates": "Wybierz dzień początkowy i końcowy", + "done": "Gotowe", + "customRange": "Własny zakres, {range}" + }, + "range": { + "all": "Kiedykolwiek", + "day": "Ostatnie 24 godziny", + "week": "Ostatni tydzień", + "month": "Ostatni miesiąc", + "year": "Ostatni rok", + "custom": "Własny zakres" + }, + "timeline": { + "items": "{count, plural, one {# element} few {# elementy} many {# elementów} other {# elementu}}", + "in_context": "w {context}" } }, "notifications": { diff --git a/apps/web/src/package-messages.gen.ts b/apps/web/src/package-messages.gen.ts index 8ddfb5874d..f64e07a84d 100644 --- a/apps/web/src/package-messages.gen.ts +++ b/apps/web/src/package-messages.gen.ts @@ -30,4 +30,7 @@ export const packageMessages: Record = { '@vitnode/example': { en: async () => await import('@vitnode/example/locales/en.json'), }, + '@vitnode/forum': { + en: async () => await import('@vitnode/forum/locales/en.json'), + }, } diff --git a/apps/web/src/page-cache.gen.ts b/apps/web/src/page-cache.gen.ts index 50d1476b50..c48778d544 100644 --- a/apps/web/src/page-cache.gen.ts +++ b/apps/web/src/page-cache.gen.ts @@ -39,15 +39,25 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/admin/core/ai' }, { path: '/admin/core/debug' }, { path: '/admin/core/users' }, + { path: '/forum/create/topic' }, + { path: '/forum/me/pending' }, + { path: '/forum/topics/:id/edit' }, { path: '/example/guide/:topic' }, + { 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: '/admin/example' }, + { path: '/discussions/new' }, { path: '/example/browse' }, { path: '/example/embed' }, { path: '/example/guide' }, { path: '/example/zones' }, { path: '/login/reset-password' }, { path: '/login/verify-email' }, + { path: '/modcp/approvals' }, + { path: '/modcp/reports' }, { path: '/settings/ai' }, { path: '/settings/devices' }, { path: '/settings/notifications' }, @@ -55,19 +65,27 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/settings/sso' }, { path: '/admin/core' }, { path: '/docs/search' }, + { path: '/admin/containers/*' }, { path: '/admin/content/*' }, + { path: '/discussions/:id/edit' }, { path: '/advanced-articles/:slug' }, { path: '/articles/:slug' }, { path: '/blog/:slug', maxAge: 300, staleWhileRevalidate: 60 }, + { path: '/boards/:slug' }, + { path: '/discussions/:slug' }, { path: '/events/:slug' }, { path: '/example/:slug' }, + { path: '/forum/:slug', maxAge: 60, staleWhileRevalidate: 30 }, { path: '/localized-articles/:slug' }, { path: '/users/:nameCode' }, { path: '/solutions/:slug' }, { path: '/admin' }, + { path: '/boards' }, { path: '/discover' }, { path: '/files' }, + { path: '/forum', maxAge: 60, staleWhileRevalidate: 30 }, { path: '/login' }, + { path: '/modcp' }, { path: '/notifications' }, { path: '/register' }, { path: '/search' }, diff --git a/apps/web/src/plugin-routes.gen.ts b/apps/web/src/plugin-routes.gen.ts index c7ce13eae9..33a29ea2f6 100644 --- a/apps/web/src/plugin-routes.gen.ts +++ b/apps/web/src/plugin-routes.gen.ts @@ -21,6 +21,7 @@ import type { PluginRouteDeclarationSource } from '@vitnode/core/routing' import { routes as pluginRoutes0 } from '@vitnode/blog/routes' import { routes as pluginRoutes1 } from '@vitnode/core/routes' import { routes as pluginRoutes2 } from '@vitnode/example/routes' +import { routes as pluginRoutes3 } from '@vitnode/forum/routes' export const pluginRouteSources = [ @@ -36,4 +37,8 @@ export const pluginRouteSources = [ pluginId: '@vitnode/example', routes: pluginRoutes2, }, + { + pluginId: '@vitnode/forum', + routes: pluginRoutes3, + }, ] as const satisfies readonly PluginRouteDeclarationSource[] diff --git a/apps/web/src/vitnode.api.config.ts b/apps/web/src/vitnode.api.config.ts index 623c3ba95a..e6f55309f9 100644 --- a/apps/web/src/vitnode.api.config.ts +++ b/apps/web/src/vitnode.api.config.ts @@ -2,6 +2,7 @@ import { blogApiPlugin } from '@vitnode/blog/config.api' import { coreRelations } from '@vitnode/core/database/relations' import { buildApiConfig } from '@vitnode/core/vitnode.config' import { exampleApiPlugin } from '@vitnode/example/config.api' +import { forumApiPlugin } from '@vitnode/forum/config.api' import { SupabaseStorageAdapter } from '@vitnode/supabase-storage' import { config } from 'dotenv' import { drizzle } from 'drizzle-orm/postgres-js' @@ -26,7 +27,7 @@ export const POSTGRES_URL = * them - `buildApiConfig` treats all of them as optional. */ export const vitNodeApiConfig = buildApiConfig({ - plugins: [blogApiPlugin(), exampleApiPlugin()], + plugins: [blogApiPlugin(), exampleApiPlugin(), forumApiPlugin()], contentModules, storage: { image: { diff --git a/apps/web/src/vitnode.config.ts b/apps/web/src/vitnode.config.ts index 34374619d8..733c4024ee 100644 --- a/apps/web/src/vitnode.config.ts +++ b/apps/web/src/vitnode.config.ts @@ -2,6 +2,7 @@ import { blogPlugin } from '@vitnode/blog/config' import { LogoVitNodeBrand } from '@vitnode/core/components/logo-vitnode' import { buildConfig } from '@vitnode/core/vitnode.config' import { examplePlugin } from '@vitnode/example/config' +import { forumPlugin } from '@vitnode/forum/config' export const vitNodeConfig = buildConfig({ debug: false, @@ -40,7 +41,7 @@ export const vitNodeConfig = buildConfig({ shortTitle: 'VitNode', title: 'VitNode', }, - plugins: [blogPlugin(), examplePlugin()], + plugins: [blogPlugin(), examplePlugin(), forumPlugin()], theme: { defaultTheme: 'system', }, diff --git a/packages/create-vitnode-app/src/create/package-versions.ts b/packages/create-vitnode-app/src/create/package-versions.ts index 9c2188b7ae..b2c715ed1c 100644 --- a/packages/create-vitnode-app/src/create/package-versions.ts +++ b/packages/create-vitnode-app/src/create/package-versions.ts @@ -35,7 +35,7 @@ export const versionsPackageJson = { cva: "^0.7.1", tanstackReactForm: "^1.33", tanstackReactRouter: "^1.170", - tanstackReactStart: "^1.168", + tanstackReactStart: "^1.169", tanstackReactQuery: "^5.104", tanstackRouterSsrQuery: "^1.167", tanstackReactDevtools: "^0.10", diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index db19cdc1e6..dc0c87a071 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -32,7 +32,7 @@ "@tanstack/react-form": "^1.33.x", "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.x", - "@tanstack/react-start": "^1.168.x", + "@tanstack/react-start": "^1.169.x", "@types/react": "^19.1.x", "@types/react-dom": "^19.1.x", "drizzle-kit": "^1.0.0-rc.4", @@ -52,8 +52,8 @@ "@react-email/ui": "^6.11.1", "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.104.1", - "@tanstack/react-router": "^1.170.41", - "@tanstack/react-start": "^1.168.60", + "@tanstack/react-router": "^1.170.44", + "@tanstack/react-start": "^1.169.1", "@testing-library/dom": "^10.4.2", "@testing-library/react": "^16.3.3", "@types/d3-shape": "^3.2.0", diff --git a/packages/vitnode/src/api/adapters/search/postgres.ts b/packages/vitnode/src/api/adapters/search/postgres.ts index 9ffd4fa650..d5b9be94a5 100644 --- a/packages/vitnode/src/api/adapters/search/postgres.ts +++ b/packages/vitnode/src/api/adapters/search/postgres.ts @@ -5,6 +5,7 @@ import type { Context } from "hono"; import { and, arrayContains, + arrayOverlaps, asc, count, desc, @@ -129,6 +130,11 @@ const buildFilters = ( arrayContains(core_search_index.authorIds, [params.authorId]), ); } + if (params.authorIds?.length) { + conditions.push( + arrayOverlaps(core_search_index.authorIds, [...params.authorIds]), + ); + } if (params.containerId !== undefined) { conditions.push(eq(core_search_index.containerId, params.containerId)); } @@ -155,7 +161,7 @@ export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({ capabilities: { authorBoost: false, canonicalStorage: true, - facets: false, + facets: true, languageScopedDelete: true, timeDecay: false, }, @@ -177,6 +183,17 @@ export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({ } }, + countByType: async (c: Context, params: SearchQueryParams) => { + const rows = await c + .get("db") + .select({ itemType: core_search_index.itemType, total: count() }) + .from(core_search_index) + .where(buildFilters(c, params)) + .groupBy(core_search_index.itemType); + + return Object.fromEntries(rows.map(row => [row.itemType, row.total])); + }, + search: async ( c: Context, params: SearchQueryParams, diff --git a/packages/vitnode/src/api/lib/moderation/registry.test.ts b/packages/vitnode/src/api/lib/moderation/registry.test.ts index 35d45fcb8d..d33dade0c5 100644 --- a/packages/vitnode/src/api/lib/moderation/registry.test.ts +++ b/packages/vitnode/src/api/lib/moderation/registry.test.ts @@ -101,10 +101,50 @@ describe("withModerationPermissions", () => { ]); }); - it("keeps a module the plugin declared itself", () => { - const declared = { moderator: { topics: ["can_view_reports"] } }; + it.each([ + ["by default", undefined], + ["when listed as declared", new Set(["topics"])], + ])("keeps a module the plugin declared itself %s", (_label, declared) => { + const permissionStaff = { moderator: { topics: ["can_view_reports"] } }; + + expect( + withModerationPermissions(permissionStaff, [target()], declared), + ).toEqual(permissionStaff); + }); - expect(withModerationPermissions(declared, [target()])).toEqual(declared); + it("merges into a generated module, keeping its entries and skipping duplicates", () => { + const generated = withModerationPermissions( + { + moderator: { + topics: [ + { permission: "can_edit", scope: FAMILY }, + { permission: "can_view_reports", scope: FAMILY }, + ], + }, + }, + [target()], + new Set(), + ); + + expect(generated?.moderator?.topics).toEqual([ + { permission: "can_edit", scope: FAMILY }, + { permission: "can_view_reports", scope: FAMILY }, + { + dependsOn: ["can_view_reports"], + permission: "can_manage_reports", + scope: FAMILY, + }, + { + dependsOn: ["can_manage_reports"], + permission: "can_edit_notes", + scope: FAMILY, + }, + { + dependsOn: ["can_manage_reports"], + permission: "can_delete_notes", + scope: FAMILY, + }, + ]); }); }); diff --git a/packages/vitnode/src/api/lib/moderation/registry.ts b/packages/vitnode/src/api/lib/moderation/registry.ts index 5ae22f4060..0cd952de54 100644 --- a/packages/vitnode/src/api/lib/moderation/registry.ts +++ b/packages/vitnode/src/api/lib/moderation/registry.ts @@ -62,16 +62,33 @@ export const moderationPermissionEntries = ( ]; }; +const permissionNameOf = (entry: PermissionStaffEntryInput): string => + typeof entry === "string" ? entry : entry.permission; + +/** + * Adds the report permissions of every target. A module the plugin declared + * itself (`declared`) is left alone; a generated one - such as a content type's + * `can_edit` - is merged with them instead of shadowing them. + */ export const withModerationPermissions = ( permissionStaff: PermissionStaffConfig | undefined, targets: readonly ModerationReportTargetDefinition[], + declared?: ReadonlySet, ): PermissionStaffConfig | undefined => { if (targets.length === 0) return permissionStaff; const moderator = { ...permissionStaff?.moderator }; for (const target of targets) { - if (moderator[target.permissionModule]) continue; - moderator[target.permissionModule] = moderationPermissionEntries(target); + const existing = moderator[target.permissionModule]; + if (existing && (declared?.has(target.permissionModule) ?? true)) continue; + + const taken = new Set((existing ?? []).map(permissionNameOf)); + moderator[target.permissionModule] = [ + ...(existing ?? []), + ...moderationPermissionEntries(target).filter( + entry => !taken.has(permissionNameOf(entry)), + ), + ]; } return { ...permissionStaff, moderator }; diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index 8bd15bf6b3..a1b441f015 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -16,6 +16,7 @@ import { contentContainerPermissionEntries, validateContentContainers, } from "@/content/containers/registry"; +import { contentContainerModerationScope } from "@/content/containers/server/scope"; import { contentPublicUrls } from "@/content/public-urls"; import { validateContentTypes, @@ -243,8 +244,19 @@ export function buildApiPlugin< registeredContainers, ), reportTargets, + new Set(Object.keys(permissionStaff?.moderator ?? {})), ), - moderation: { reportTargets, scopes: moderation?.scopes ?? [] }, + moderation: { + reportTargets, + scopes: [ + ...(moderation?.scopes ?? []), + ...registeredContainers + .filter(definition => definition.policies.enabled) + .map(definition => + contentContainerModerationScope(definition, pluginId), + ), + ], + }, }; } diff --git a/packages/vitnode/src/api/lib/public-people.ts b/packages/vitnode/src/api/lib/public-people.ts new file mode 100644 index 0000000000..b26f1e49bb --- /dev/null +++ b/packages/vitnode/src/api/lib/public-people.ts @@ -0,0 +1,58 @@ +import type { Context } from "hono"; + +import { eq, inArray } from "drizzle-orm"; + +import type { ContentPublicPerson } from "@/content/public-person"; + +import { storageUrlOf } from "@/api/lib/storage-url"; +import { core_files } from "@/database/files"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; + +export type { ContentPublicPerson } from "@/content/public-person"; +export { zodContentPublicPerson as zodPublicPerson } from "@/content/public-person"; + +export type PublicPerson = ContentPublicPerson; + +/** One query for every person a page names, keyed by user id. */ +export const loadPublicPeople = async ( + c: Context, + ids: readonly unknown[], +): Promise> => { + const unique = [ + ...new Set( + ids.filter((id): id is number => typeof id === "number" && id > 0), + ), + ]; + if (unique.length === 0) return new Map(); + + const rows = await c + .get("db") + .select({ + avatarColor: core_users.avatarColor, + avatarKey: core_files.key, + id: core_users.id, + name: core_users.name, + nameCode: core_users.nameCode, + roleColor: core_roles.color, + rolePrefix: core_roles.prefix, + }) + .from(core_users) + .leftJoin(core_roles, eq(core_roles.id, core_users.roleId)) + .leftJoin(core_files, eq(core_files.id, core_users.avatarId)) + .where(inArray(core_users.id, unique)); + + return new Map( + rows.map(row => [ + row.id, + { + avatarColor: row.avatarColor, + avatarUrl: storageUrlOf(c, row.avatarKey), + id: row.id, + name: row.name, + nameCode: row.nameCode, + role: { color: row.roleColor ?? null, prefix: row.rolePrefix ?? null }, + }, + ]), + ); +}; diff --git a/packages/vitnode/src/api/lib/response-cache-headers.test.ts b/packages/vitnode/src/api/lib/response-cache-headers.test.ts index 0742a6eb13..0af0c49b58 100644 --- a/packages/vitnode/src/api/lib/response-cache-headers.test.ts +++ b/packages/vitnode/src/api/lib/response-cache-headers.test.ts @@ -111,6 +111,7 @@ const isSharedCacheable = (value: string): boolean => { }; const DECLARED = { + "api/modules/moderation/routes/approvals.routes.ts": ["private, no-store"], "content/server/delivery-routes.ts": ["private, no-store"], "content/server/public-routes.ts": ["private, no-store"], "content/server/public-write-routes.ts": ["private, no-store"], diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 6c440826aa..044aecee9f 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -14,6 +14,16 @@ export interface ContentContainerEventPayload { pluginId: string; } +export interface ContentApprovalEventPayload { + authorId: null | number; + containerId: null | number; + contentId: string; + contentTypeId: string; + kind: "create" | "edit"; + pluginId: string; + submissionId: number; +} + export interface VitNodeEvents { "ai.run.completed": { actionKey: string; @@ -28,6 +38,17 @@ export interface VitNodeEvents { runId: number; userId: null | number; }; + "content.approval.approved": ContentApprovalEventPayload & { + reviewerId: number; + }; + "content.approval.rejected": ContentApprovalEventPayload & { + reason: null | string; + reviewerId: number; + }; + "content.approval.submitted": ContentApprovalEventPayload & { + /** Id of the pending proposal this one replaced, if any. */ + replacedSubmissionId: null | number; + }; "content.container.created": ContentContainerEventPayload; "content.container.deleted": ContentContainerEventPayload; "content.container.moved": ContentContainerEventPayload & { diff --git a/packages/vitnode/src/api/models/search.ts b/packages/vitnode/src/api/models/search.ts index a2906bbdf6..f5b9c3114c 100644 --- a/packages/vitnode/src/api/models/search.ts +++ b/packages/vitnode/src/api/models/search.ts @@ -1,6 +1,16 @@ import type { Context } from "hono"; -import { and, eq, inArray } from "drizzle-orm"; +import { + and, + asc, + count, + desc, + eq, + exists, + ilike, + inArray, + sql, +} from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import type { ContentId } from "@/content/ids"; @@ -12,6 +22,7 @@ import { contentLocalesMatch } from "@/content/locale"; import { core_files } from "@/database/files"; import { core_search_index } from "@/database/search"; import { core_users } from "@/database/users"; +import { containsLikePattern } from "@/lib/api/like-pattern"; import { parseImageDimensions } from "@/lib/api/upload"; import { stripHtml } from "@/lib/strip-html"; @@ -44,6 +55,7 @@ export interface SearchDocument { export interface SearchQueryParams { authorId?: number; + authorIds?: readonly number[]; containerId?: number; cursor?: string; dateFrom?: Date; @@ -281,6 +293,10 @@ export interface SearchProviderApiPlugin { c: Context, args: { itemType: string; languageCode?: string }, ) => Promise; + countByType?: ( + c: Context, + params: SearchQueryParams, + ) => Promise>; /** * Removes one item's documents. * @@ -490,6 +506,114 @@ export class SearchModel { }); } + private withFallbacks(params: SearchQueryParams): SearchQueryParams { + const languageFallbacks = params.languageCode + ? searchLanguageFallbacks( + this.c.get("core").contentTypes, + params.languageCode, + ) + : []; + + return languageFallbacks.length > 0 + ? { ...params, languageFallbacks } + : params; + } + + async authors({ + limit, + term, + }: { + limit: number; + term?: string; + }): Promise { + const db = this.c.get("db"); + const listed = and( + eq(core_search_index.isPublic, true), + eq(core_search_index.isHidden, false), + ); + const columns = { + id: core_users.id, + name: core_users.name, + nameCode: core_users.nameCode, + avatarColor: core_users.avatarColor, + avatarKey: authorAvatarFile.key, + }; + const toAuthor = ({ + avatarKey, + ...row + }: Omit & { + avatarKey: null | string; + }): SearchHitAuthor => ({ + ...row, + avatarUrl: storageUrlOf(this.c, avatarKey), + }); + + if (term) { + const rows = await db + .select(columns) + .from(core_users) + .leftJoin( + authorAvatarFile, + eq(authorAvatarFile.id, core_users.avatarId), + ) + .where( + and( + ilike(core_users.name, containsLikePattern(term)), + exists( + db + .select({ id: core_search_index.id }) + .from(core_search_index) + .where( + and( + listed, + sql`${core_search_index.authorIds} @> ARRAY[${core_users.id}]::integer[]`, + ), + ), + ), + ), + ) + .orderBy(asc(core_users.name)) + .limit(limit); + + return rows.map(toAuthor); + } + + const activity = db + .select({ + authorId: sql`unnest(${core_search_index.authorIds})`.as( + "author_id", + ), + }) + .from(core_search_index) + .where(listed) + .as("activity"); + const top = await db + .select({ authorId: activity.authorId, total: count() }) + .from(activity) + .groupBy(activity.authorId) + .orderBy(desc(count()), asc(activity.authorId)) + .limit(limit); + if (top.length === 0) return []; + + const rows = await db + .select(columns) + .from(core_users) + .leftJoin(authorAvatarFile, eq(authorAvatarFile.id, core_users.avatarId)) + .where( + inArray( + core_users.id, + top.map(row => row.authorId), + ), + ); + const byId = new Map(rows.map(row => [row.id, toAuthor(row)])); + + return top.flatMap(row => { + const author = byId.get(row.authorId); + + return author ? [author] : []; + }); + } + async bulkIndex(docs: SearchDocument[]): Promise { const clean = docs.map(doc => this.resolveOwner({ ...doc, content: stripHtml(doc.content) }), @@ -511,6 +635,15 @@ export class SearchModel { await this.provider().clear(this.c, itemType); } + async countByType( + params: SearchQueryParams, + ): Promise> { + const provider = this.provider(); + if (!provider.countByType) return null; + + return await provider.countByType(this.c, this.withFallbacks(params)); + } + /** * How many documents the **provider** holds, or `null` when it cannot say. * @@ -557,6 +690,36 @@ export class SearchModel { await this.provider().delete(this.c, itemType, itemId, languageCode); } + async deleteByContainer({ + containerId, + containerType, + itemType, + }: { + containerId: number; + containerType: string; + itemType: string; + }): Promise { + const rows = await this.c + .get("db") + .selectDistinct({ itemId: core_search_index.itemId }) + .from(core_search_index) + .where( + and( + eq(core_search_index.itemType, itemType), + eq(core_search_index.containerType, containerType), + eq(core_search_index.containerId, containerId), + ), + ); + + const contentTypes = this.c.get("core")?.contentTypes ?? []; + for (const { itemId } of rows) { + await this.delete( + itemType, + searchItemIdFromKey(contentTypes, itemType, itemId), + ); + } + } + /** Canonical projection lives in `core_search_index`; the provider mirrors it. */ async index(doc: SearchDocument): Promise { const clean = this.resolveOwner({ @@ -589,15 +752,9 @@ export class SearchModel { } async search(params: SearchQueryParams): Promise { - const languageFallbacks = params.languageCode - ? searchLanguageFallbacks( - this.c.get("core").contentTypes, - params.languageCode, - ) - : []; const result = await this.provider().search( this.c, - languageFallbacks.length > 0 ? { ...params, languageFallbacks } : params, + this.withFallbacks(params), ); return { diff --git a/packages/vitnode/src/api/modules/moderation/approvals.test.ts b/packages/vitnode/src/api/modules/moderation/approvals.test.ts new file mode 100644 index 0000000000..b3c189d230 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/approvals.test.ts @@ -0,0 +1,321 @@ +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; + +import { core_content_revisions } from "@/database/content"; +import { + BOARD, + createPolicyWorld, + POLICY_LIVE, + POLICY_PLUGIN_ID, + policyTopicRow, + policyTopics, + USER, +} from "@/tests/content-policies"; + +const TOPIC = { CHILD: 20, DRAFT: 31, LIVE: 30, OPEN: 10 } as const; + +/** Seeded submissions, oldest first. */ +const SUBMISSION = { CHILD: 4, CREATE: 1, EDIT: 2, OPEN: 3 } as const; + +const submission = ( + id: number, + itemId: number, + containerId: number, + overrides: Record = {}, +) => ({ + authorId: USER.MEMBER, + baseUpdatedAt: POLICY_LIVE, + baseValues: { board: containerId, title: `Topic ${itemId}` }, + baseVersion: 1, + containerDefinitionId: "test.policy-boards", + containerId, + contentTypeId: policyTopics.definition.id, + createdAt: new Date(POLICY_LIVE.getTime() + id * 60_000), + decidedAt: null, + id, + itemId: String(itemId), + kind: "edit", + pluginId: POLICY_PLUGIN_ID, + reason: null, + revisionId: null, + reviewerId: null, + status: "pending", + updatedAt: POLICY_LIVE, + values: { title: "Proposed title" }, + ...overrides, +}); + +const world = async () => + await createPolicyWorld({ + submissions: [ + submission(SUBMISSION.CREATE, TOPIC.DRAFT, BOARD.APPROVE, { + baseUpdatedAt: null, + baseValues: null, + baseVersion: null, + kind: "create", + values: { board: BOARD.APPROVE, title: `Topic ${TOPIC.DRAFT}` }, + }), + submission(SUBMISSION.EDIT, TOPIC.LIVE, BOARD.APPROVE), + submission(SUBMISSION.OPEN, TOPIC.OPEN, BOARD.OPEN, { + authorId: USER.OTHER_MEMBER, + }), + submission(SUBMISSION.CHILD, TOPIC.CHILD, BOARD.OPEN_CHILD, { + authorId: USER.OTHER_MEMBER, + }), + ], + topics: [ + policyTopicRow(TOPIC.OPEN, BOARD.OPEN), + policyTopicRow(TOPIC.CHILD, BOARD.OPEN_CHILD), + policyTopicRow(TOPIC.LIVE, BOARD.APPROVE), + policyTopicRow(TOPIC.DRAFT, BOARD.APPROVE, { + publishedAt: null, + status: "draft", + }), + ], + }); + +const topic = ( + memory: Awaited>["memory"], + id: number, +) => memory.rows(policyTopics.table).find(row => row.id === id); + +const submissionRow = ( + submissions: Awaited>["submissions"], + id: number, +) => submissions().find(row => row.id === id); + +const idsOf = async (response: Response) => + ((await response.json()) as { items: { id: number }[] }).items.map( + item => item.id, + ); + +describe("GET /moderation/approvals", () => { + it.each([ + ["a moderator of one board", USER.MOD_APPROVE, [1, 2]], + ["a moderator of a board but not its children", USER.MOD_OPEN, [3]], + ["a moderator of a board's subtree", USER.MOD_OPEN_TREE, [3, 4]], + ["a root moderator", USER.ROOT, [1, 2, 3, 4]], + ])("shows %s only the submissions in scope", async (_label, user, ids) => { + const { send } = await world(); + + const response = await send("GET", "/moderation/approvals", { user }); + + expect(response.status).toBe(200); + await expect(idsOf(response)).resolves.toEqual(ids); + }); + + it.each([ + ["an anonymous visitor", undefined, 401], + ["a member who is not staff", USER.MEMBER, 403], + ])("refuses %s with %i", async (_label, user, status) => { + const { send } = await world(); + + const response = await send("GET", "/moderation/approvals", { user }); + + expect(response.status).toBe(status); + }); +}); + +describe("a submission outside the moderator's scope", () => { + it.each([ + ["GET", `/moderation/approvals/${SUBMISSION.CREATE}`, undefined], + ["POST", `/moderation/approvals/${SUBMISSION.CREATE}/approve`, undefined], + [ + "POST", + `/moderation/approvals/${SUBMISSION.CREATE}/reject`, + { reason: "No" }, + ], + ])( + "answers %s %s with 404 and changes nothing", + async (method, path, body) => { + const { events, memory, send, submissions } = await world(); + const before = [submissions(), memory.rows(policyTopics.table)]; + + const response = await send(method, path, { body, user: USER.MOD_OPEN }); + + expect(response.status).toBe(404); + expect([submissions(), memory.rows(policyTopics.table)]).toEqual(before); + expect(events()).toEqual([]); + }, + ); +}); + +describe("approving and rejecting", () => { + it("publishes an approved create and announces it once", async () => { + const { emitted, events, memory, send, submissions } = await world(); + + const response = await send( + "POST", + `/moderation/approvals/${SUBMISSION.CREATE}/approve`, + { user: USER.MOD_APPROVE }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + id: SUBMISSION.CREATE, + status: "approved", + }); + expect(topic(memory, TOPIC.DRAFT)).toMatchObject({ + publishedAt: expect.any(Date), + status: "published", + }); + expect(submissionRow(submissions, SUBMISSION.CREATE)).toMatchObject({ + decidedAt: expect.any(Date), + reviewerId: USER.MOD_APPROVE, + status: "approved", + }); + expect( + events().filter(name => name === "content.test.policy-topic.published"), + ).toHaveLength(1); + expect( + emitted.filter(event => event.name === "content.approval.approved"), + ).toEqual([ + { + name: "content.approval.approved", + payload: expect.objectContaining({ + authorId: USER.MEMBER, + containerId: BOARD.APPROVE, + contentId: String(TOPIC.DRAFT), + kind: "create", + reviewerId: USER.MOD_APPROVE, + submissionId: SUBMISSION.CREATE, + }), + }, + ]); + expect((await send("GET", `/topics/topic-${TOPIC.DRAFT}`)).status).toBe( + 200, + ); + }); + + it("applies an approved edit as a new version", async () => { + const { events, memory, send } = await world(); + + const response = await send( + "POST", + `/moderation/approvals/${SUBMISSION.EDIT}/approve`, + { user: USER.MOD_APPROVE }, + ); + + expect(response.status).toBe(200); + expect(topic(memory, TOPIC.LIVE)).toMatchObject({ + title: "Proposed title", + version: 2, + }); + expect(memory.rows(core_content_revisions)).toEqual([ + expect.objectContaining({ itemId: String(TOPIC.LIVE), version: 2 }), + ]); + expect(events()).toEqual([ + "content.test.policy-topic.updated", + "content.approval.approved", + ]); + }); + + it("keeps the live values on a rejection and stores the reason", async () => { + const { emitted, memory, send, submissions } = await world(); + const before = topic(memory, TOPIC.LIVE); + + const response = await send( + "POST", + `/moderation/approvals/${SUBMISSION.EDIT}/reject`, + { body: { reason: "Off topic" }, user: USER.MOD_APPROVE }, + ); + + expect(response.status).toBe(200); + expect(topic(memory, TOPIC.LIVE)).toEqual(before); + expect(submissionRow(submissions, SUBMISSION.EDIT)).toMatchObject({ + reason: "Off topic", + status: "rejected", + }); + expect(emitted).toEqual([ + { + name: "content.approval.rejected", + payload: expect.objectContaining({ + reason: "Off topic", + submissionId: SUBMISSION.EDIT, + }), + }, + ]); + }); + + it("answers a second decision with 409 decided", async () => { + const { send } = await world(); + await send("POST", `/moderation/approvals/${SUBMISSION.EDIT}/approve`, { + user: USER.MOD_APPROVE, + }); + + const response = await send( + "POST", + `/moderation/approvals/${SUBMISSION.EDIT}/reject`, + { body: { reason: "Changed my mind" }, user: USER.MOD_APPROVE }, + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ error: "decided" }); + }); + + it("answers 409 stale when a moderator edited the record since the proposal", async () => { + const { memory, send, submissions } = await world(); + const edited = await send("PATCH", `/topics/${TOPIC.LIVE}`, { + body: { expectedVersion: 1, values: { title: "Moderator title" } }, + user: USER.MOD_APPROVE, + }); + expect(edited.status).toBe(200); + + const response = await send( + "POST", + `/moderation/approvals/${SUBMISSION.EDIT}/approve`, + { user: USER.MOD_APPROVE }, + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ error: "stale" }); + expect(topic(memory, TOPIC.LIVE)).toMatchObject({ + title: "Moderator title", + }); + expect(submissionRow(submissions, SUBMISSION.EDIT)).toMatchObject({ + status: "pending", + }); + }); + + it("cancels the submission of a record that is gone and answers 409 gone", async () => { + const { events, memory, send, submissions } = await world(); + await memory.db + .delete(policyTopics.table) + .where(eq(policyTopics.table.id, TOPIC.DRAFT)); + + const response = await send( + "POST", + `/moderation/approvals/${SUBMISSION.CREATE}/approve`, + { user: USER.MOD_APPROVE }, + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ error: "gone" }); + expect(submissionRow(submissions, SUBMISSION.CREATE)).toMatchObject({ + status: "cancelled", + }); + expect(events()).toEqual([]); + }); +}); + +describe("GET /moderation/approvals/mine", () => { + it("lists only the signed-in author's submissions, newest first", async () => { + const { send } = await world(); + + const response = await send("GET", "/moderation/approvals/mine", { + user: USER.MEMBER, + }); + + expect(response.status).toBe(200); + await expect(idsOf(response)).resolves.toEqual([ + SUBMISSION.EDIT, + SUBMISSION.CREATE, + ]); + }); + + it("asks an anonymous visitor to sign in", async () => { + const { send } = await world(); + + expect((await send("GET", "/moderation/approvals/mine")).status).toBe(401); + }); +}); diff --git a/packages/vitnode/src/api/modules/moderation/lib/people.ts b/packages/vitnode/src/api/modules/moderation/lib/people.ts index 340ab682ca..f57b193beb 100644 --- a/packages/vitnode/src/api/modules/moderation/lib/people.ts +++ b/packages/vitnode/src/api/modules/moderation/lib/people.ts @@ -1,57 +1,4 @@ -import type { Context } from "hono"; - -import { eq, inArray } from "drizzle-orm"; - -import { storageUrlOf } from "@/api/lib/storage-url"; -import { core_files } from "@/database/files"; -import { core_roles } from "@/database/roles"; -import { core_users } from "@/database/users"; - -export interface ReportPerson { - avatarColor: string; - avatarUrl: null | string; - id: number; - name: string; - nameCode: string; - role: { color: null | string; prefix: null | string }; -} - -export const loadReportPeople = async ( - c: Context, - ids: readonly (null | number | undefined)[], -): Promise> => { - const unique = [ - ...new Set(ids.filter((id): id is number => typeof id === "number")), - ]; - if (unique.length === 0) return new Map(); - - const rows = await c - .get("db") - .select({ - avatarColor: core_users.avatarColor, - avatarKey: core_files.key, - id: core_users.id, - name: core_users.name, - nameCode: core_users.nameCode, - roleColor: core_roles.color, - rolePrefix: core_roles.prefix, - }) - .from(core_users) - .leftJoin(core_roles, eq(core_roles.id, core_users.roleId)) - .leftJoin(core_files, eq(core_files.id, core_users.avatarId)) - .where(inArray(core_users.id, unique)); - - return new Map( - rows.map(row => [ - row.id, - { - avatarColor: row.avatarColor, - avatarUrl: storageUrlOf(c, row.avatarKey), - id: row.id, - name: row.name, - nameCode: row.nameCode, - role: { color: row.roleColor ?? null, prefix: row.rolePrefix ?? null }, - }, - ]), - ); -}; +export { + loadPublicPeople as loadReportPeople, + type PublicPerson as ReportPerson, +} from "@/api/lib/public-people"; diff --git a/packages/vitnode/src/api/modules/moderation/moderation.module.ts b/packages/vitnode/src/api/modules/moderation/moderation.module.ts index 930fe3d0ee..e8384dfefe 100644 --- a/packages/vitnode/src/api/modules/moderation/moderation.module.ts +++ b/packages/vitnode/src/api/modules/moderation/moderation.module.ts @@ -1,6 +1,14 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; +import { + approvalFiltersRoute, + approveApprovalRoute, + listApprovalsRoute, + myApprovalsRoute, + rejectApprovalRoute, + showApprovalRoute, +} from "./routes/approvals.routes"; import { createReportRoute } from "./routes/create.route"; import { listReportsRoute } from "./routes/list.route"; import { @@ -34,5 +42,11 @@ export const moderationModule = buildModule({ reopenReportRoute, reportActionRoute, reportAssigneesRoute, + listApprovalsRoute, + approvalFiltersRoute, + myApprovalsRoute, + showApprovalRoute, + approveApprovalRoute, + rejectApprovalRoute, ], }); diff --git a/packages/vitnode/src/api/modules/moderation/routes/approvals.routes.ts b/packages/vitnode/src/api/modules/moderation/routes/approvals.routes.ts new file mode 100644 index 0000000000..a985238563 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/routes/approvals.routes.ts @@ -0,0 +1,345 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import { requireModerator } from "@/api/lib/moderation/access"; +import { zodPublicPerson } from "@/api/lib/public-people"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { + CONTENT_SUBMISSION_DECISION_CODES, + CONTENT_SUBMISSION_KINDS, + CONTENT_SUBMISSION_LIMITS, + CONTENT_SUBMISSION_STATUSES, +} from "@/content/approvals/const"; +import { + ContentSubmissionDecisionError, + decideContentSubmission, +} from "@/content/approvals/server/decide"; +import { + canReviewSubmission, + contentSubmissionFilters, + findContentSubmission, + listAuthorContentSubmissions, + listContentSubmissions, + reviewableContentTypes, + showContentSubmission, +} from "@/content/approvals/server/queue"; + +import { json } from "../lib/schema"; + +const containerSchema = z.object({ + id: z.number(), + name: z.string(), + path: z.array(z.string()), + slug: z.string(), +}); + +const itemSchema = z.object({ + author: zodPublicPerson.nullable(), + container: containerSchema.nullable(), + contentTypeId: z.string(), + createdAt: z.date(), + decidedAt: z.date().nullable(), + id: z.number(), + itemId: z.string(), + kind: z.enum(CONTENT_SUBMISSION_KINDS), + pluginId: z.string(), + reason: z.string().nullable(), + reviewer: zodPublicPerson.nullable(), + status: z.enum(CONTENT_SUBMISSION_STATUSES), + title: z.string().nullable(), +}); + +const pageSchema = z.object({ + items: z.array(itemSchema), + page: z.number(), + pageSize: z.number(), + total: z.number(), +}); + +const detailSchema = itemSchema.extend({ + baseValues: z.record(z.string(), z.unknown()).nullable(), + current: z.record(z.string(), z.unknown()).nullable(), + fields: z.array(z.object({ kind: z.string(), name: z.string() })), + history: z.array(itemSchema), + record: z.object({ + exists: z.boolean(), + public: z.boolean(), + slug: z.string().nullable(), + url: z.string().nullable(), + }), + values: z.record(z.string(), z.unknown()), +}); + +const decisionErrorSchema = z.object({ + error: z.enum(CONTENT_SUBMISSION_DECISION_CODES), + message: z.string(), +}); + +const pageQuery = { + page: z.coerce.number().int().positive().max(10_000).default(1), + pageSize: z.coerce + .number() + .int() + .positive() + .max(CONTENT_SUBMISSION_LIMITS.maxPageSize) + .default(CONTENT_SUBMISSION_LIMITS.pageSize), +}; + +const idParams = z.object({ + id: z.coerce.number().int().positive().openapi({ example: 1 }), +}); + +const decisionError = (error: unknown): never => { + if (error instanceof ContentSubmissionDecisionError) { + throw new HTTPException(error.status, { + res: Response.json( + { error: error.code, message: error.message }, + { status: error.status }, + ), + }); + } + + throw error; +}; + +/** 404 for a submission the moderator may not review - its existence is not theirs to know. */ +const reviewableSubmission = async (c: Context, id: number) => { + const moderator = await requireModerator(c); + const row = await findContentSubmission(c, id); + if ( + !row || + !canReviewSubmission(await reviewableContentTypes(c, moderator.set), row) + ) { + throw new HTTPException(404, { + res: Response.json( + { error: "notFound", message: "No such submission." }, + { status: 404 }, + ), + }); + } + + return { moderator, row }; +}; + +export const listApprovalsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/approvals", + description: + "The approval queue - only submissions in containers where the moderator holds `can_approve`", + request: { + query: z.object({ + ...pageQuery, + status: z.enum(CONTENT_SUBMISSION_STATUSES).default("pending"), + contentTypeId: z.string().max(100).optional(), + containerId: z.coerce.number().int().positive().optional(), + }), + }, + responses: { + 200: json(pageSchema, "One page of submissions"), + 401: { description: "Sign in first" }, + 403: { description: "Not a moderator" }, + }, + }, + handler: async c => { + const moderator = await requireModerator(c); + const query = c.req.valid("query"); + const { items, total } = await listContentSubmissions( + c, + moderator.set, + query, + ); + + return c.json( + { items, page: query.page, pageSize: query.pageSize, total }, + 200, + { "Cache-Control": "private, no-store" }, + ); + }, +}); + +export const approvalFiltersRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/approvals/filters", + description: + "Content types and containers the moderator may review, for the queue filters", + responses: { + 200: json( + z.object({ + containers: z.array( + z.object({ + definitionId: z.string(), + depth: z.number(), + id: z.number(), + name: z.string(), + }), + ), + contentTypes: z.array( + z.object({ id: z.string(), pluginId: z.string() }), + ), + }), + "Filter options", + ), + 401: { description: "Sign in first" }, + 403: { description: "Not a moderator" }, + }, + }, + handler: async c => { + const moderator = await requireModerator(c); + + return c.json(await contentSubmissionFilters(c, moderator.set), 200, { + "Cache-Control": "private, no-store", + }); + }, +}); + +export const myApprovalsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/approvals/mine", + description: + "The signed-in member's own submissions and what happened to them", + request: { query: z.object(pageQuery) }, + responses: { + 200: json(pageSchema, "One page of the member's submissions"), + 401: { description: "Sign in first" }, + }, + }, + handler: async c => { + const user = c.get("user"); + if (!user) throw new HTTPException(401, { message: "Sign in first." }); + const query = c.req.valid("query"); + const { items, total } = await listAuthorContentSubmissions( + c, + user.id, + query, + ); + + return c.json( + { items, page: query.page, pageSize: query.pageSize, total }, + 200, + { "Cache-Control": "private, no-store" }, + ); + }, +}); + +export const showApprovalRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/approvals/{id}", + description: + "One submission with the proposed values, the live record to compare with, and its history", + request: { params: idParams }, + responses: { + 200: json(detailSchema, "The submission"), + 401: { description: "Sign in first" }, + 403: { description: "Not a moderator" }, + 404: json(decisionErrorSchema, "No such submission in your scope"), + }, + }, + handler: async c => { + const { row } = await reviewableSubmission(c, c.req.valid("param").id); + + return c.json(await showContentSubmission(c, row), 200, { + "Cache-Control": "private, no-store", + }); + }, +}); + +const decisionResponses = { + 200: json(itemSchema.pick({ id: true, status: true }), "Decided"), + 401: { description: "Sign in first" }, + 403: json(decisionErrorSchema, "The record moved out of your scope"), + 404: json(decisionErrorSchema, "No such submission in your scope"), + 409: json( + decisionErrorSchema, + "Already decided, the record is gone, or it changed since the proposal", + ), +}; + +export const approveApprovalRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + path: "/approvals/{id}/approve", + description: + "Approve a submission: publish the new record, or apply the proposed edit", + request: { params: idParams }, + responses: decisionResponses, + }, + handler: async c => { + const { moderator, row } = await reviewableSubmission( + c, + c.req.valid("param").id, + ); + + try { + const decided = await decideContentSubmission(c, { + decision: "approve", + id: row.id, + reviewerId: moderator.user.id, + }); + + return c.json({ id: decided.id, status: decided.status }, 200); + } catch (error) { + return decisionError(error); + } + }, +}); + +export const rejectApprovalRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + path: "/approvals/{id}/reject", + description: + "Reject a submission. The record stays as it is: a new one stays unpublished, a published one keeps its live version", + request: { + params: idParams, + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + reason: z + .string() + .trim() + .max(CONTENT_SUBMISSION_LIMITS.reasonMaxLength) + .nullable() + .optional(), + }), + }, + }, + }, + }, + responses: decisionResponses, + }, + handler: async c => { + const { moderator, row } = await reviewableSubmission( + c, + c.req.valid("param").id, + ); + const { reason } = c.req.valid("json"); + + try { + const decided = await decideContentSubmission(c, { + decision: "reject", + id: row.id, + reason: reason === "" ? null : (reason ?? null), + reviewerId: moderator.user.id, + }); + + return c.json({ id: decided.id, status: decided.status }, 200); + } catch (error) { + return decisionError(error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/search/routes/authors.route.ts b/packages/vitnode/src/api/modules/search/routes/authors.route.ts new file mode 100644 index 0000000000..580f747765 --- /dev/null +++ b/packages/vitnode/src/api/modules/search/routes/authors.route.ts @@ -0,0 +1,43 @@ +import { z } from "@hono/zod-openapi"; + +import { CONFIG_PLUGIN } from "@/config"; + +import { buildRoute } from "../../../lib/route"; +import { zodSearchHitAuthor } from "./search.route"; + +export const SEARCH_AUTHORS_LIMIT = 10; + +export const authorsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: + "Members credited on public indexed content. With a term, members whose name contains it; without one, the most active members first.", + path: "/authors", + request: { + query: z.object({ + search: z.string().trim().max(100).optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ authors: z.array(zodSearchHitAuthor) }), + }, + }, + description: "Authors", + }, + }, + }, + handler: async c => { + const { search } = c.req.valid("query"); + + const authors = await c.get("search").authors({ + limit: SEARCH_AUTHORS_LIMIT, + term: search === "" ? undefined : search, + }); + + return c.json({ authors }); + }, +}); diff --git a/packages/vitnode/src/api/modules/search/routes/facets.route.ts b/packages/vitnode/src/api/modules/search/routes/facets.route.ts new file mode 100644 index 0000000000..22fc22915e --- /dev/null +++ b/packages/vitnode/src/api/modules/search/routes/facets.route.ts @@ -0,0 +1,59 @@ +import { z } from "@hono/zod-openapi"; + +import { CONFIG_PLUGIN } from "@/config"; +import { contentSearchHiddenAccess } from "@/content/server/hidden-access"; + +import { buildRoute } from "../../../lib/route"; +import { + dateOrUndefined, + positiveIntListOrUndefined, + positiveIntOrUndefined, +} from "./search.route"; + +export const facetsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: + "How many results each content type would have under the given filters. `types` is null when the search provider cannot count.", + path: "/facets", + request: { + query: z.object({ + search: z.string().optional(), + authorIds: z.string().max(512).optional(), + containerId: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + lang: z.string().optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + types: z.record(z.string(), z.number()).nullable(), + }), + }, + }, + description: "Result counts per content type", + }, + }, + }, + handler: async c => { + const query = c.req.valid("query"); + + const types = await c.get("search").countByType({ + term: query.search, + authorIds: positiveIntListOrUndefined(query.authorIds), + containerId: positiveIntOrUndefined(query.containerId), + dateFrom: dateOrUndefined(query.from), + dateTo: dateOrUndefined(query.to), + languageCode: query.lang, + hiddenAccess: await contentSearchHiddenAccess(c), + includePrivate: false, + }); + + return c.json({ types }); + }, +}); diff --git a/packages/vitnode/src/api/modules/search/routes/search.route.ts b/packages/vitnode/src/api/modules/search/routes/search.route.ts index 770f6246be..d0903ced24 100644 --- a/packages/vitnode/src/api/modules/search/routes/search.route.ts +++ b/packages/vitnode/src/api/modules/search/routes/search.route.ts @@ -15,7 +15,7 @@ const zodSearchPageInfo = z.object({ endCursor: z.number().nullable(), }); -const zodSearchHitAuthor = z.object({ +export const zodSearchHitAuthor = z.object({ id: z.number(), name: z.string(), nameCode: z.string(), @@ -73,8 +73,29 @@ export const positiveIntOrUndefined = ( return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; }; +export const MAX_SEARCH_AUTHOR_IDS = 20; + +export const positiveIntListOrUndefined = ( + value: string | undefined, +): number[] | undefined => { + if (!value) return undefined; + + const ids = [ + ...new Set( + value + .split(",") + .map(entry => positiveIntOrUndefined(entry.trim())) + .filter(id => id !== undefined), + ), + ].slice(0, MAX_SEARCH_AUTHOR_IDS); + + return ids.length > 0 ? ids : undefined; +}; + /** A query parameter as a real date, or nothing. See above. */ -const dateOrUndefined = (value: string | undefined): Date | undefined => { +export const dateOrUndefined = ( + value: string | undefined, +): Date | undefined => { if (value === undefined || value === "") return undefined; const parsed = new Date(value); @@ -102,6 +123,7 @@ export const searchRoute = buildRoute({ search: z.string().optional(), types: z.string().optional(), authorId: z.string().optional(), + authorIds: z.string().max(512).optional(), containerId: z.string().optional(), sort: z.enum(["newest", "oldest", "relevance"]).optional(), from: z.string().optional(), @@ -129,6 +151,7 @@ export const searchRoute = buildRoute({ ? query.types.split(",").filter(Boolean) : undefined, authorId: positiveIntOrUndefined(query.authorId), + authorIds: positiveIntListOrUndefined(query.authorIds), containerId: positiveIntOrUndefined(query.containerId), sort: query.sort, dateFrom: dateOrUndefined(query.from), diff --git a/packages/vitnode/src/api/modules/search/search.module.ts b/packages/vitnode/src/api/modules/search/search.module.ts index 6bf4bac4e9..bb47fa87cf 100644 --- a/packages/vitnode/src/api/modules/search/search.module.ts +++ b/packages/vitnode/src/api/modules/search/search.module.ts @@ -1,6 +1,8 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; +import { authorsRoute } from "./routes/authors.route"; +import { facetsRoute } from "./routes/facets.route"; import { searchRoute } from "./routes/search.route"; import { timelineRoute } from "./routes/timeline.route"; import { rebuildSearchIndexTask } from "./tasks/rebuild-index.task"; @@ -8,6 +10,6 @@ import { rebuildSearchIndexTask } from "./tasks/rebuild-index.task"; export const searchModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "search", - routes: [searchRoute, timelineRoute], + routes: [searchRoute, timelineRoute, authorsRoute, facetsRoute], queueTasks: [rebuildSearchIndexTask], }); diff --git a/packages/vitnode/src/components/form/fields/nullable-number.tsx b/packages/vitnode/src/components/form/fields/nullable-number.tsx index ca772faa44..e83829fbec 100644 --- a/packages/vitnode/src/components/form/fields/nullable-number.tsx +++ b/packages/vitnode/src/components/form/fields/nullable-number.tsx @@ -23,6 +23,8 @@ type AutoFormNullableNumberProps = ItemAutoFormComponentProps & > & { orLabel?: React.ReactNode; placeholder?: string; + /** The number restored when the toggle is unchecked before anything was typed. */ + resumeValue?: number; toggleLabel: React.ReactNode; unitLabel?: React.ReactNode; }; @@ -42,6 +44,7 @@ export const AutoFormNullableNumber = ({ className, disabled, placeholder, + resumeValue = 0, unitLabel, orLabel, toggleLabel, @@ -50,7 +53,7 @@ export const AutoFormNullableNumber = ({ const { formItemId } = useFormField(); const isToggled = field.value === null; const lastNumericRef = React.useRef( - typeof field.value === "number" ? field.value : 0, + typeof field.value === "number" ? field.value : resumeValue, ); return ( diff --git a/packages/vitnode/src/content/advanced.test.ts b/packages/vitnode/src/content/advanced.test.ts index f8dd522d84..9e6e0c989e 100644 --- a/packages/vitnode/src/content/advanced.test.ts +++ b/packages/vitnode/src/content/advanced.test.ts @@ -100,6 +100,32 @@ describe("relations", () => { expect(definition.advanced.junctions).toStrictEqual([]); }); + it("binds a nullable to-one `self: true` relation without a junction table", () => { + const definition = defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + parent: field.relation({ nullable: true, self: true }), + }, + }); + + expect(definition.advanced.junctions).toStrictEqual([]); + expect(definition.fields.parent.target()).toBe(definition); + }); + + it("rejects a to-one `self: true` relation that is not nullable", () => { + // The first record would have nothing to point at. + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + parent: field.relation({ required: true, self: true }), + }, + }), + ).toThrow(/Self-relation "parent" is to-one but not nullable/); + }); + it("rejects `ordered` without `multiple`", () => { expect(() => defineContentType({ diff --git a/packages/vitnode/src/content/approvals/const.ts b/packages/vitnode/src/content/approvals/const.ts new file mode 100644 index 0000000000..051853bb0b --- /dev/null +++ b/packages/vitnode/src/content/approvals/const.ts @@ -0,0 +1,35 @@ +export const CONTENT_SUBMISSION_KINDS = ["create", "edit"] as const; + +export type ContentSubmissionKind = (typeof CONTENT_SUBMISSION_KINDS)[number]; + +/** + * `pending` waits for a moderator. `superseded` was replaced by the author's + * newer proposal, `cancelled` lost its record before anyone decided. + */ +export const CONTENT_SUBMISSION_STATUSES = [ + "pending", + "approved", + "rejected", + "superseded", + "cancelled", +] as const; + +export type ContentSubmissionStatus = + (typeof CONTENT_SUBMISSION_STATUSES)[number]; + +export const CONTENT_SUBMISSION_DECISION_CODES = [ + "decided", + "forbidden", + "gone", + "notFound", + "stale", +] as const; + +export type ContentSubmissionDecisionCode = + (typeof CONTENT_SUBMISSION_DECISION_CODES)[number]; + +export const CONTENT_SUBMISSION_LIMITS = { + pageSize: 25, + maxPageSize: 100, + reasonMaxLength: 2000, +} as const; diff --git a/packages/vitnode/src/content/approvals/schema.ts b/packages/vitnode/src/content/approvals/schema.ts new file mode 100644 index 0000000000..69501b9a2a --- /dev/null +++ b/packages/vitnode/src/content/approvals/schema.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +import type { ContentSubmissionKind, ContentSubmissionStatus } from "./const"; + +import { CONTENT_SUBMISSION_KINDS, CONTENT_SUBMISSION_STATUSES } from "./const"; + +/** What a public-write response tells its author about moderation. */ +export interface ContentApprovalState { + id: number; + kind: ContentSubmissionKind; + /** Why a moderator said no. Only on a rejection. */ + reason: null | string; + status: ContentSubmissionStatus; + submittedAt: Date; + /** The proposed values of an edit still waiting for a moderator. */ + values: null | Record; +} + +export const zodContentApprovalState = z.object({ + id: z.number(), + kind: z.enum(CONTENT_SUBMISSION_KINDS), + reason: z.string().nullable(), + status: z.enum(CONTENT_SUBMISSION_STATUSES), + submittedAt: z.coerce.date(), + values: z.record(z.string(), z.unknown()).nullable(), +}); diff --git a/packages/vitnode/src/content/approvals/server/decide.ts b/packages/vitnode/src/content/approvals/server/decide.ts new file mode 100644 index 0000000000..35fa2e6c05 --- /dev/null +++ b/packages/vitnode/src/content/approvals/server/decide.ts @@ -0,0 +1,334 @@ +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { eq } from "drizzle-orm"; + +import type { ContentId } from "../../ids"; +import type { ContentActor } from "../../revisions"; +import type { ContentEditorialOutcome } from "../../server/editorial-service"; +import type { AnyContentModel } from "../../server/model"; +import type { ContentDatabase } from "../../server/service"; +import type { AnyContentTypeDefinition } from "../../types"; +import type { ContentSubmissionDecisionCode } from "../const"; +import type { ContentSubmissionRow } from "./submissions"; + +import { core_content_submissions } from "../../../database/content-submissions"; +import { CONTENT_PERMISSIONS } from "../../const"; +import { contentContainerSourceOf } from "../../containers/association"; +import { + canModerateContent, + resolveContentRowContainer, +} from "../../containers/server/policy"; +import { ContentInputError, ContentVersionConflict } from "../../errors"; +import { parseContentId } from "../../ids"; +import { contentDefinitionOf } from "../../server/model"; +import { publicationMethods } from "../../server/publication"; +import { + contentEditorialWriteEffects, + contentPlainWriteEffects, +} from "../../server/write-effects"; + +const STATUS: Record = { + decided: 409, + forbidden: 403, + gone: 409, + notFound: 404, + stale: 409, +}; + +export class ContentSubmissionDecisionError extends Error { + constructor(code: ContentSubmissionDecisionCode, message?: string) { + super(message ?? `The submission cannot be decided: ${code}.`); + this.name = "ContentSubmissionDecisionError"; + this.code = code; + this.status = STATUS[code]; + } + + readonly code: ContentSubmissionDecisionCode; + readonly status: 403 | 404 | 409; +} + +export const findSubmissionModel = ( + c: Context, + submission: Pick, +): AnyContentModel | null => + (c.get("core")?.contentModels ?? []).find( + entry => + entry.pluginId === submission.pluginId && + entry.model.definition.id === submission.contentTypeId, + )?.model ?? null; + +const lockRecord = async ( + tx: ContentDatabase, + model: AnyContentModel, + id: ContentId, +): Promise> => { + const definition = contentDefinitionOf(model); + const columns = model.columns as Record; + const table: PgTable = model.table; + const source = contentContainerSourceOf(definition); + const [row] = await tx + .select({ + id: columns.id, + publishedAt: columns.publishedAt, + status: columns.status, + updatedAt: columns.updatedAt, + ...(definition.editorial.enabled ? { version: columns.version } : {}), + ...(source && columns[source.name] + ? { [source.name]: columns[source.name] } + : {}), + }) + .from(table) + .where(eq(columns.id, id)) + .limit(1) + .for("update"); + + return row ?? null; +}; + +type Applied = + | { + changedFields: string[]; + kind: "plain"; + operation: "publish" | "update"; + row: Record; + } + | { + kind: "editorial"; + outcome: ContentEditorialOutcome | null; + } + | { kind: "none" }; + +const applyApproval = async ( + c: Context, + tx: ContentDatabase, + { + actor, + id, + model, + pluginId, + row, + submission, + }: { + actor: ContentActor; + id: ContentId; + model: AnyContentModel; + pluginId: string; + row: Record; + submission: ContentSubmissionRow; + }, +): Promise => { + const definition = contentDefinitionOf(model); + const editorial = definition.editorial.enabled; + + if (submission.kind === "create") { + if (row.status === "published") return { kind: "none" }; + + if (editorial) { + const service = model.editorialService?.(c, { pluginId }); + + return { + kind: "editorial", + outcome: (await service?.publish(id, { actor, tx })) ?? null, + }; + } + + const published = await publicationMethods( + definition, + model.service(c), + ).publish(id, { tx }); + + return published + ? { + changedFields: [], + kind: "plain", + operation: "publish", + row: published.row, + } + : { kind: "none" }; + } + + try { + if (editorial) { + if (row.version !== submission.baseVersion) { + throw new ContentSubmissionDecisionError("stale"); + } + const service = model.editorialService?.(c, { pluginId }); + + return { + kind: "editorial", + outcome: + (await service?.update(id, submission.values, { + actor, + expectedVersion: submission.baseVersion ?? 0, + tx, + })) ?? null, + }; + } + + const base = submission.baseUpdatedAt?.getTime(); + if ( + base !== undefined && + (row.updatedAt as Date | undefined)?.getTime() !== base + ) { + throw new ContentSubmissionDecisionError("stale"); + } + const updated = await model + .service(c) + .update(id, submission.values, { tx }); + + return updated + ? { + changedFields: updated.changedFields, + kind: "plain", + operation: "update", + row: updated.row, + } + : { kind: "none" }; + } catch (error) { + if (error instanceof ContentVersionConflict) { + throw new ContentSubmissionDecisionError("stale"); + } + if (error instanceof ContentInputError) { + throw new ContentSubmissionDecisionError("stale", error.message); + } + throw error; + } +}; + +export interface ContentSubmissionDecision { + decision: "approve" | "reject"; + id: number; + reason?: null | string; + reviewerId: number; +} + +/** + * Approves or rejects one submission. + * + * The submission row is locked first and must still be `pending`, so two + * moderators deciding at once get one success and one `decided`. The record is + * locked next, its container is resolved again from the row - wherever it + * lives now - and `can_approve` is checked against that container. Approving a + * create publishes the draft; approving an edit applies the proposal against + * the version it was made from, and answers `stale` if the record moved since. + */ +export const decideContentSubmission = async ( + c: Context, + { decision, id, reason = null, reviewerId }: ContentSubmissionDecision, +): Promise => { + const actor: ContentActor = { type: "api", userId: reviewerId }; + + const result = await c.get("db").transaction(async tx => { + const [submission] = await tx + .select() + .from(core_content_submissions) + .where(eq(core_content_submissions.id, id)) + .limit(1) + .for("update"); + if (!submission) throw new ContentSubmissionDecisionError("notFound"); + if (submission.status !== "pending") { + throw new ContentSubmissionDecisionError("decided"); + } + + const cancel = async () => { + await tx + .update(core_content_submissions) + .set({ decidedAt: new Date(), status: "cancelled" }) + .where(eq(core_content_submissions.id, submission.id)); + + return { kind: "gone" as const }; + }; + + const model = findSubmissionModel(c, submission); + const definition = model ? contentDefinitionOf(model) : null; + const itemId = definition + ? parseContentId(definition.idStrategy, submission.itemId) + : null; + if (!model || !definition || itemId === null) return await cancel(); + + const row = await lockRecord(tx, model, itemId); + if (!row) return await cancel(); + + const container = await resolveContentRowContainer(c, tx, definition, row); + const allowed = await canModerateContent(c, { + container, + definition, + permission: CONTENT_PERMISSIONS.approve, + pluginId: submission.pluginId, + }); + if (!allowed) throw new ContentSubmissionDecisionError("forbidden"); + + const applied: Applied = + decision === "approve" + ? await applyApproval(c, tx, { + actor, + id: itemId, + model, + pluginId: submission.pluginId, + row, + submission, + }) + : { kind: "none" }; + + const [decided] = await tx + .update(core_content_submissions) + .set({ + containerDefinitionId: + container?.definition.id ?? submission.containerDefinitionId, + containerId: container?.container.id ?? submission.containerId, + decidedAt: new Date(), + reason: decision === "reject" ? reason : null, + reviewerId, + revisionId: + applied.kind === "editorial" + ? (applied.outcome?.revisionId ?? null) + : null, + status: decision === "approve" ? "approved" : "rejected", + }) + .where(eq(core_content_submissions.id, submission.id)) + .returning(); + + return { applied, decided, kind: "decided" as const, model }; + }); + + if (result.kind === "gone") { + throw new ContentSubmissionDecisionError("gone"); + } + + const { applied, decided, model } = result; + if (applied.kind === "editorial") { + await contentEditorialWriteEffects(c, model, { + outcomes: [applied.outcome], + pluginId: decided.pluginId, + }); + } else if (applied.kind === "plain") { + await contentPlainWriteEffects(c, model, { + changedFields: applied.changedFields, + operation: applied.operation, + pluginId: decided.pluginId, + row: applied.row, + }); + } + + const payload = { + authorId: decided.authorId, + containerId: decided.containerId, + contentId: decided.itemId, + contentTypeId: decided.contentTypeId, + kind: decided.kind, + pluginId: decided.pluginId, + reviewerId, + submissionId: decided.id, + }; + if (decided.status === "approved") { + await c.get("events").emit("content.approval.approved", payload); + } else { + await c.get("events").emit("content.approval.rejected", { + ...payload, + reason: decided.reason, + }); + } + + return decided; +}; diff --git a/packages/vitnode/src/content/approvals/server/queue.ts b/packages/vitnode/src/content/approvals/server/queue.ts new file mode 100644 index 0000000000..0b9503b92e --- /dev/null +++ b/packages/vitnode/src/content/approvals/server/queue.ts @@ -0,0 +1,540 @@ +import type { SQL } from "drizzle-orm"; +import type { Context } from "hono"; + +import { and, asc, count, desc, eq, inArray, or } from "drizzle-orm"; + +import type { ResolvedStaffPermissionSet } from "../../../api/lib/permission-staff"; +import type { PublicPerson } from "../../../api/lib/public-people"; +import type { AnyContentModel } from "../../server/model"; +import type { AnyContentTypeDefinition } from "../../types"; +import type { ContentSubmissionKind, ContentSubmissionStatus } from "../const"; +import type { ContentSubmissionRow } from "./submissions"; + +import { loadPublicPeople } from "../../../api/lib/public-people"; +import { staffPermissionResources } from "../../../api/lib/staff-permission"; +import { core_content_containers } from "../../../database/content-containers"; +import { core_content_submissions } from "../../../database/content-submissions"; +import { + CONTENT_PERMISSIONS, + CONTENT_SEARCH_SLUG_PLACEHOLDER, +} from "../../const"; +import { contentContainerSourceOf } from "../../containers/association"; +import { expandContentContainerScope } from "../../containers/server/policy"; +import { contentContainerAncestors } from "../../containers/tree"; +import { parseContentId } from "../../ids"; +import { contentDefinitionOf } from "../../server/model"; +import { isContentBaseRowPublic } from "../../server/visibility"; +import { findSubmissionModel } from "./decide"; + +export interface ReviewableContentType { + containerDefinitionId: string; + definition: AnyContentTypeDefinition; + model: AnyContentModel; + pluginId: string; + /** Container ids the moderator may approve in, or every one. */ + scope: "all" | number[]; +} + +/** Content types the moderator may approve in, each with the containers covered. */ +export const reviewableContentTypes = async ( + c: Context, + set: ResolvedStaffPermissionSet, +): Promise => { + const expanded = new Map>(); + const entries = await Promise.all( + (c.get("core")?.contentModels ?? []).map(async ({ model, pluginId }) => { + const definition = contentDefinitionOf(model); + const container = contentContainerSourceOf(definition)?.container; + if (!definition.publicWrite.enabled || !container?.policies.enabled) { + return null; + } + + const resources = staffPermissionResources( + set, + { + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.approve, + plugin: pluginId, + }, + container.id, + ); + if (resources === null) return null; + + const key = `${container.id}|${resources === "all" ? "*" : resources.join(",")}`; + const pending = + expanded.get(key) ?? + expandContentContainerScope(c.get("db"), container.id, resources); + expanded.set(key, pending); + const scope = await pending; + if (scope !== "all" && scope.length === 0) return null; + + return { + containerDefinitionId: container.id, + definition, + model, + pluginId, + scope, + }; + }), + ); + + return entries.filter(entry => entry !== null); +}; + +const reviewableCondition = (types: readonly ReviewableContentType[]): SQL => { + const conditions = types.map(entry => + and( + eq(core_content_submissions.pluginId, entry.pluginId), + eq(core_content_submissions.contentTypeId, entry.definition.id), + entry.scope === "all" + ? undefined + : inArray(core_content_submissions.containerId, entry.scope), + ), + ); + + return ( + (conditions.length > 0 ? or(...conditions) : undefined) ?? + eq(core_content_submissions.id, 0) + ); +}; + +const textOf = (value: unknown): null | string => { + if (typeof value === "string") return value.trim() || null; + if (typeof value !== "object" || value === null) return null; + + const parts: string[] = []; + const walk = (node: unknown): void => { + if (typeof node !== "object" || node === null || parts.length > 64) return; + const { content, text } = node as { content?: unknown; text?: unknown }; + if (typeof text === "string") parts.push(text); + if (Array.isArray(content)) content.forEach(walk); + }; + walk(value); + const joined = parts.join(" ").replace(/\s+/g, " ").trim(); + + return joined === "" ? null : joined; +}; + +/** A one-line title for a queue row: the title field, else the start of the text. */ +const titleOf = ( + definition: AnyContentTypeDefinition | null, + values: Record, +): null | string => { + const titleField = definition?.admin.titleField; + const title = titleField ? textOf(values[titleField]) : null; + if (title) return title.slice(0, 200); + + for (const [name, value] of Object.entries(values)) { + const kind = definition?.fields[name]?.kind; + if (kind !== "richText" && kind !== "textarea" && kind !== "text") continue; + const text = textOf(value); + if (text) return text.length > 140 ? `${text.slice(0, 139)}…` : text; + } + + return null; +}; + +interface ContainerLabel { + id: number; + name: string; + path: string[]; + slug: string; +} + +const loadContainerLabels = async ( + c: Context, + ids: readonly (null | number)[], +): Promise> => { + const wanted = [...new Set(ids.filter(id => id !== null))]; + if (wanted.length === 0) return new Map(); + + const owners = await c + .get("db") + .select({ definitionId: core_content_containers.definitionId }) + .from(core_content_containers) + .where(inArray(core_content_containers.id, wanted)); + const definitions = [...new Set(owners.map(row => row.definitionId))]; + if (definitions.length === 0) return new Map(); + + const rows = await c + .get("db") + .select({ + id: core_content_containers.id, + name: core_content_containers.name, + parentId: core_content_containers.parentId, + position: core_content_containers.position, + slug: core_content_containers.slug, + }) + .from(core_content_containers) + .where(inArray(core_content_containers.definitionId, definitions)); + + return new Map( + rows + .filter(row => wanted.includes(row.id)) + .map(row => [ + row.id, + { + id: row.id, + name: row.name, + path: contentContainerAncestors(rows, row.id).map( + ancestor => ancestor.name, + ), + slug: row.slug, + }, + ]), + ); +}; + +export interface ContentSubmissionListItem { + author: null | PublicPerson; + container: ContainerLabel | null; + contentTypeId: string; + createdAt: Date; + decidedAt: Date | null; + id: number; + itemId: string; + kind: ContentSubmissionKind; + pluginId: string; + reason: null | string; + reviewer: null | PublicPerson; + status: ContentSubmissionStatus; + title: null | string; +} + +const toListItems = async ( + c: Context, + rows: readonly ContentSubmissionRow[], +): Promise => { + const [people, containers] = await Promise.all([ + loadPublicPeople( + c, + rows.flatMap(row => [row.authorId, row.reviewerId]), + ), + loadContainerLabels( + c, + rows.map(row => row.containerId), + ), + ]); + + return rows.map(row => { + const model = findSubmissionModel(c, row); + + return { + author: row.authorId === null ? null : (people.get(row.authorId) ?? null), + container: + row.containerId === null + ? null + : (containers.get(row.containerId) ?? null), + contentTypeId: row.contentTypeId, + createdAt: row.createdAt, + decidedAt: row.decidedAt, + id: row.id, + itemId: row.itemId, + kind: row.kind, + pluginId: row.pluginId, + reason: row.reason, + reviewer: + row.reviewerId === null ? null : (people.get(row.reviewerId) ?? null), + status: row.status, + title: titleOf(model ? contentDefinitionOf(model) : null, row.values), + }; + }); +}; + +export interface ContentSubmissionQueueQuery { + containerId?: number; + contentTypeId?: string; + page: number; + pageSize: number; + status: ContentSubmissionStatus; +} + +export const listContentSubmissions = async ( + c: Context, + set: ResolvedStaffPermissionSet, + { + containerId, + contentTypeId, + page, + pageSize, + status, + }: ContentSubmissionQueueQuery, +): Promise<{ items: ContentSubmissionListItem[]; total: number }> => { + const types = await reviewableContentTypes(c, set); + const where = and( + reviewableCondition(types), + eq(core_content_submissions.status, status), + contentTypeId === undefined + ? undefined + : eq(core_content_submissions.contentTypeId, contentTypeId), + containerId === undefined + ? undefined + : eq(core_content_submissions.containerId, containerId), + ); + const order = + status === "pending" + ? [ + asc(core_content_submissions.createdAt), + asc(core_content_submissions.id), + ] + : [ + desc(core_content_submissions.decidedAt), + desc(core_content_submissions.id), + ]; + + const [[total], rows] = await Promise.all([ + c + .get("db") + .select({ value: count() }) + .from(core_content_submissions) + .where(where), + c + .get("db") + .select() + .from(core_content_submissions) + .where(where) + .orderBy(...order) + .limit(pageSize) + .offset((page - 1) * pageSize), + ]); + + return { items: await toListItems(c, rows), total: total?.value ?? 0 }; +}; + +/** Whether this moderator may see (and decide) one submission. */ +export const canReviewSubmission = ( + types: readonly ReviewableContentType[], + row: Pick, +): boolean => + types.some( + entry => + entry.pluginId === row.pluginId && + entry.definition.id === row.contentTypeId && + (entry.scope === "all" || + (row.containerId !== null && entry.scope.includes(row.containerId))), + ); + +export interface ContentSubmissionField { + kind: string; + name: string; +} + +export interface ContentSubmissionDetail extends ContentSubmissionListItem { + baseValues: null | Record; + /** The record as it is now - what readers see, for comparison. */ + current: null | Record; + fields: ContentSubmissionField[]; + history: ContentSubmissionListItem[]; + record: { + exists: boolean; + public: boolean; + slug: null | string; + url: null | string; + }; + values: Record; +} + +export const showContentSubmission = async ( + c: Context, + row: ContentSubmissionRow, +): Promise => { + const model = findSubmissionModel(c, row); + const definition = model ? contentDefinitionOf(model) : null; + const itemId = definition + ? parseContentId(definition.idStrategy, row.itemId) + : null; + const live = + model && itemId !== null + ? ((await model.service(c).findById(itemId)) as null | Record< + string, + unknown + >) + : null; + + const readable = definition + ? [ + ...new Set([ + ...definition.publicWrite.create.fields, + ...definition.publicWrite.update.fields, + ]), + ] + : Object.keys(row.values); + const collections = + live && model && itemId !== null + ? await model.service(c).advancedFields( + itemId, + readable.filter(name => { + const field = definition?.fields[name]; + + return ( + (field?.kind === "relation" || field?.kind === "file") && + field.multiple + ); + }), + ) + : {}; + const current = live + ? Object.fromEntries( + readable.map(name => [name, collections[name] ?? live[name] ?? null]), + ) + : null; + + const slugField = definition?.publicApi.slugField; + const slug = + live && slugField && typeof live[slugField] === "string" + ? live[slugField] + : null; + const isPublic = live ? isContentBaseRowPublic(live) : false; + const template = + definition?.search.enabled === true ? definition.search.pathTemplate : ""; + const url = + isPublic && slug && template + ? template.replaceAll( + CONTENT_SEARCH_SLUG_PLACEHOLDER, + encodeURIComponent(slug), + ) + : null; + + const historyRows = await c + .get("db") + .select() + .from(core_content_submissions) + .where( + and( + eq(core_content_submissions.contentTypeId, row.contentTypeId), + eq(core_content_submissions.itemId, row.itemId), + ), + ) + .orderBy( + desc(core_content_submissions.createdAt), + desc(core_content_submissions.id), + ) + .limit(20); + + const [self] = await toListItems(c, [row]); + const history = await toListItems( + c, + historyRows.filter(entry => entry.id !== row.id), + ); + + return { + ...self, + baseValues: row.baseValues, + current, + fields: readable.map(name => ({ + kind: definition?.fields[name]?.kind ?? "text", + name, + })), + history, + record: { exists: live !== null, public: isPublic, slug, url }, + values: row.values, + }; +}; + +export const findContentSubmission = async ( + c: Context, + id: number, +): Promise => { + const [row] = await c + .get("db") + .select() + .from(core_content_submissions) + .where(eq(core_content_submissions.id, id)) + .limit(1); + + return row ?? null; +}; + +export const listAuthorContentSubmissions = async ( + c: Context, + authorId: number, + { page, pageSize }: { page: number; pageSize: number }, +): Promise<{ items: ContentSubmissionListItem[]; total: number }> => { + const where = and( + eq(core_content_submissions.authorId, authorId), + inArray(core_content_submissions.status, [ + "pending", + "approved", + "rejected", + ]), + ); + const [[total], rows] = await Promise.all([ + c + .get("db") + .select({ value: count() }) + .from(core_content_submissions) + .where(where), + c + .get("db") + .select() + .from(core_content_submissions) + .where(where) + .orderBy( + desc(core_content_submissions.createdAt), + desc(core_content_submissions.id), + ) + .limit(pageSize) + .offset((page - 1) * pageSize), + ]); + + return { items: await toListItems(c, rows), total: total?.value ?? 0 }; +}; + +/** Filter options for the queue: the content types and containers in scope. */ +export const contentSubmissionFilters = async ( + c: Context, + set: ResolvedStaffPermissionSet, +): Promise<{ + containers: { + definitionId: string; + depth: number; + id: number; + name: string; + }[]; + contentTypes: { id: string; pluginId: string }[]; +}> => { + const types = await reviewableContentTypes(c, set); + const definitions = [ + ...new Set(types.map(entry => entry.containerDefinitionId)), + ]; + const rows = + definitions.length === 0 + ? [] + : await c + .get("db") + .select({ + definitionId: core_content_containers.definitionId, + id: core_content_containers.id, + name: core_content_containers.name, + parentId: core_content_containers.parentId, + position: core_content_containers.position, + }) + .from(core_content_containers) + .where(inArray(core_content_containers.definitionId, definitions)) + .orderBy( + asc(core_content_containers.position), + asc(core_content_containers.id), + ); + + const covered = (definitionId: string, id: number) => + types.some( + entry => + entry.containerDefinitionId === definitionId && + (entry.scope === "all" || entry.scope.includes(id)), + ); + + return { + containers: rows + .filter(row => covered(row.definitionId, row.id)) + .map(row => ({ + definitionId: row.definitionId, + depth: contentContainerAncestors(rows, row.id).length + 1, + id: row.id, + name: row.name, + })), + contentTypes: types.map(entry => ({ + id: entry.definition.id, + pluginId: entry.pluginId, + })), + }; +}; diff --git a/packages/vitnode/src/content/approvals/server/submissions.ts b/packages/vitnode/src/content/approvals/server/submissions.ts new file mode 100644 index 0000000000..783f2353a1 --- /dev/null +++ b/packages/vitnode/src/content/approvals/server/submissions.ts @@ -0,0 +1,179 @@ +import type { Context } from "hono"; + +import { and, desc, eq } from "drizzle-orm"; + +import type { ContentRecordContainer } from "../../containers/server/policy"; +import type { ContentId } from "../../ids"; +import type { ContentDatabase } from "../../server/service"; +import type { AnyContentTypeDefinition } from "../../types"; +import type { ContentSubmissionKind } from "../const"; +import type { ContentApprovalState } from "../schema"; + +import { core_content_submissions } from "../../../database/content-submissions"; +import { contentIdKey } from "../../ids"; + +export type { ContentApprovalState } from "../schema"; + +export type ContentSubmissionRow = typeof core_content_submissions.$inferSelect; + +export const toContentApprovalState = ( + row: ContentSubmissionRow, +): ContentApprovalState => ({ + id: row.id, + kind: row.kind, + reason: row.status === "rejected" ? row.reason : null, + status: row.status, + submittedAt: row.createdAt, + values: row.status === "pending" && row.kind === "edit" ? row.values : null, +}); + +const itemCondition = (definition: AnyContentTypeDefinition, id: ContentId) => + and( + eq(core_content_submissions.contentTypeId, definition.id), + eq(core_content_submissions.itemId, contentIdKey(id)), + ); + +export const findPendingContentSubmission = async ( + db: ContentDatabase, + definition: AnyContentTypeDefinition, + id: ContentId, + { lock = false }: { lock?: boolean } = {}, +): Promise => { + const query = db + .select() + .from(core_content_submissions) + .where( + and( + itemCondition(definition, id), + eq(core_content_submissions.status, "pending"), + ), + ) + .limit(1); + const [row] = lock ? await query.for("update") : await query; + + return row ?? null; +}; + +/** The newest submission of a record, whatever its status. */ +export const findLatestContentSubmission = async ( + db: ContentDatabase, + definition: AnyContentTypeDefinition, + id: ContentId, +): Promise => { + const [row] = await db + .select() + .from(core_content_submissions) + .where(itemCondition(definition, id)) + .orderBy( + desc(core_content_submissions.createdAt), + desc(core_content_submissions.id), + ) + .limit(1); + + return row ?? null; +}; + +export interface OpenContentSubmissionInput { + authorId: number; + baseUpdatedAt?: Date | null; + baseValues?: null | Record; + baseVersion?: null | number; + container: ContentRecordContainer | null; + definition: AnyContentTypeDefinition; + itemId: ContentId; + kind: ContentSubmissionKind; + pluginId: string; + values: Record; +} + +/** + * Opens a pending submission. A record has at most one: an older pending one + * is marked `superseded` in the same transaction, and its id is returned so + * the caller can say what was replaced. + */ +export const openContentSubmission = async ( + tx: ContentDatabase, + input: OpenContentSubmissionInput, +): Promise<{ replaced: null | number; row: ContentSubmissionRow }> => { + const previous = await findPendingContentSubmission( + tx, + input.definition, + input.itemId, + { lock: true }, + ); + if (previous) { + await tx + .update(core_content_submissions) + .set({ decidedAt: new Date(), status: "superseded" }) + .where(eq(core_content_submissions.id, previous.id)); + } + + const [row] = await tx + .insert(core_content_submissions) + .values({ + authorId: input.authorId, + baseUpdatedAt: input.baseUpdatedAt ?? null, + baseValues: input.baseValues ?? null, + baseVersion: input.baseVersion ?? null, + containerDefinitionId: input.container?.definition.id ?? null, + containerId: input.container?.container.id ?? null, + contentTypeId: input.definition.id, + itemId: contentIdKey(input.itemId), + kind: input.kind, + pluginId: input.pluginId, + values: input.values, + }) + .returning(); + + return { replaced: previous?.id ?? null, row }; +}; + +/** Keeps a pending create's snapshot in step with the draft it describes. */ +export const refreshContentSubmissionValues = async ( + tx: ContentDatabase, + id: number, + values: Record, +): Promise => { + await tx + .update(core_content_submissions) + .set({ values }) + .where( + and( + eq(core_content_submissions.id, id), + eq(core_content_submissions.status, "pending"), + ), + ); +}; + +/** Ends the pending submission of a record that no longer exists. */ +export const cancelContentSubmissions = async ( + tx: ContentDatabase, + definition: AnyContentTypeDefinition, + id: ContentId, +): Promise => { + await tx + .update(core_content_submissions) + .set({ decidedAt: new Date(), status: "cancelled" }) + .where( + and( + itemCondition(definition, id), + eq(core_content_submissions.status, "pending"), + ), + ); +}; + +export const emitContentApprovalSubmitted = async ( + c: Context, + { replaced, row }: { replaced: null | number; row: ContentSubmissionRow }, +): Promise => { + await c.get("events").emit("content.approval.submitted", { + authorId: row.authorId, + containerId: row.containerId, + contentId: row.itemId, + contentTypeId: row.contentTypeId, + kind: row.kind, + pluginId: row.pluginId, + replacedSubmissionId: replaced, + submissionId: row.id, + }); +}; diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index f334665d3f..e9ce3e0234 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -91,6 +91,8 @@ export const CONTENT_TEXT_DEFAULT_LENGTH = 255; export const CONTENT_ENUM_DEFAULT_LENGTH = 64; export const CONTENT_SLUG_DEFAULT_LENGTH = 160; +/** Length of the random token `field.slug({ generated: true })` mints. */ +export const CONTENT_GENERATED_SLUG_LENGTH = 10; export const CONTENT_LOCALIZED_FIELD_KINDS = [ "blocks", @@ -252,7 +254,9 @@ export const CONTENT_PUBLIC_WRITABLE_KINDS = [ ] as const; export const CONTENT_PUBLIC_WRITE_ISSUE_CODES = { + editWindowClosed: "edit_window_closed", invalidJson: "invalid_json", + moderatorsOnly: "moderators_only", notWritable: "not_writable", unavailable: "unavailable", } as const; @@ -421,6 +425,7 @@ export const CONTENT_ADMIN_EDIT_SEGMENT = "edit"; export const CONTENT_ADMIN_PATH_SEGMENT_PATTERN = /^[a-z][a-z0-9-]*$/; export const CONTENT_PERMISSIONS = { + approve: "can_approve", create: "can_create", delete: "can_delete", edit: "can_edit", diff --git a/packages/vitnode/src/content/containers/association.ts b/packages/vitnode/src/content/containers/association.ts index a46156e1af..6414a67e75 100644 --- a/packages/vitnode/src/content/containers/association.ts +++ b/packages/vitnode/src/content/containers/association.ts @@ -1,4 +1,9 @@ -import type { AnyContentTypeDefinition, ContentContainerField } from "../types"; +import type { + AnyContentTypeDefinition, + ContentContainerField, + ContentFieldDescriptor, +} from "../types"; +import type { AnyContentContainerDefinition } from "./types"; import { ContentInputError } from "../errors"; @@ -47,3 +52,67 @@ export class ContentContainerAssignmentError extends ContentInputError { readonly detail: string; readonly field: string; } + +export type ContentContainerSource = + | { + container: AnyContentContainerDefinition; + field: ContentContainerField; + kind: "field"; + name: string; + } + | { + container: AnyContentContainerDefinition | null; + kind: "relation"; + name: string; + target: AnyContentTypeDefinition | null; + }; + +const isContainerRelation = (field: ContentFieldDescriptor): boolean => + field.kind === "relation" && + (field as { container?: boolean }).container === true && + !field.multiple; + +const relationTargetOf = ( + field: ContentFieldDescriptor, +): AnyContentTypeDefinition | null => { + if (field.kind !== "relation") return null; + try { + return field.target() ?? null; + } catch { + return null; + } +}; + +/** + * Where a content type's container comes from: its own `field.container()`, or + * a to-one `field.relation({ container: true })` whose target is filed in one. + */ +export const contentContainerSourceOf = ( + definition: Pick, +): ContentContainerSource | null => { + const own = contentContainerFieldOf(definition); + if (own) { + return { + container: own.field.definition, + field: own.field, + kind: "field", + name: own.name, + }; + } + + for (const [name, field] of Object.entries(definition.fields)) { + if (!isContainerRelation(field)) continue; + const target = relationTargetOf(field); + + return { + container: target + ? (contentContainerFieldOf(target)?.field.definition ?? null) + : null, + kind: "relation", + name, + target, + }; + } + + return null; +}; diff --git a/packages/vitnode/src/content/containers/const.ts b/packages/vitnode/src/content/containers/const.ts index 1a532c9e6f..af9f0b0060 100644 --- a/packages/vitnode/src/content/containers/const.ts +++ b/packages/vitnode/src/content/containers/const.ts @@ -47,6 +47,7 @@ export const CONTENT_CONTAINER_ERROR_CODES = [ "hasContent", "invalidInput", "invalidMetadata", + "invalidPolicy", "invalidPosition", "limitReached", "maxDepth", @@ -55,3 +56,28 @@ export const CONTENT_CONTAINER_ERROR_CODES = [ "slugEmpty", "slugTaken", ] as const; + +export const CONTENT_CONTAINER_POLICY_CREATE = [ + "members", + "moderators", +] as const; + +export const CONTENT_CONTAINER_POLICY_MODES = ["immediate", "approve"] as const; + +export const CONTENT_CONTAINER_POLICY_AUTHOR_EDIT = [ + "always", + "never", + "window", +] as const; + +export const CONTENT_CONTAINER_POLICY_KEYS = [ + "create", + "publish", + "edits", + "authorEdit", + "authorEditMinutes", +] as const; + +export const CONTENT_CONTAINER_POLICY_MAX_MINUTES = 60 * 24 * 365; + +export const CONTENT_CONTAINER_TREE_SUFFIX = ":tree"; diff --git a/packages/vitnode/src/content/containers/define.ts b/packages/vitnode/src/content/containers/define.ts index 755f5d1b57..1183668eed 100644 --- a/packages/vitnode/src/content/containers/define.ts +++ b/packages/vitnode/src/content/containers/define.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { ContentFieldDescriptor, ContentFieldMap } from "../types"; +import type { ContentContainerPoliciesInput } from "./policies"; import type { ContentContainerAdminInput, ContentContainerDefinition, @@ -37,6 +38,7 @@ import { CONTENT_CONTAINER_RESERVED_FIELDS, CONTENT_CONTAINER_SLUG_MAX_LENGTH, } from "./const"; +import { resolveContentContainerPolicies } from "./policies"; const fieldKinds: ReadonlySet = new Set(CONTENT_CONTAINER_FIELD_KINDS); const reservedFields: readonly string[] = CONTENT_CONTAINER_RESERVED_FIELDS; @@ -337,6 +339,7 @@ export const defineContentContainer = < hierarchy, id, ordering, + policies, publicApi, }: { admin?: ContentContainerAdminInput; @@ -344,6 +347,11 @@ export const defineContentContainer = < hierarchy?: ContentContainerHierarchyInput; id: TId; ordering?: ContentContainerOrderingInput; + /** + * Per-container publishing rules for the content types filed here. Omit it + * and those content types keep their own `publicWrite` behaviour. + */ + policies?: ContentContainerPoliciesInput | false; publicApi?: ( | ContentContainerPublicApiInput | false @@ -381,6 +389,7 @@ export const defineContentContainer = < id, kind: "contentContainer", ordering: resolveOrdering(id, ordering), + policies: resolveContentContainerPolicies(id, policies), publicApi: resolvedPublicApi as never, schemas: buildSchemas(fields, resolvedPublicApi.fields), }; diff --git a/packages/vitnode/src/content/containers/errors.ts b/packages/vitnode/src/content/containers/errors.ts index e13d15cceb..04e4cfdced 100644 --- a/packages/vitnode/src/content/containers/errors.ts +++ b/packages/vitnode/src/content/containers/errors.ts @@ -12,6 +12,8 @@ const MESSAGES: Record = { "The container still holds content. Move or delete that content first.", invalidInput: "The request does not describe a valid container.", invalidMetadata: "The container's values do not match its definition.", + invalidPolicy: + "The publishing policy does not match what this collection supports.", invalidPosition: "The position is outside the list of siblings.", limitReached: "This collection already holds as many containers as it can.", maxDepth: "The container would be nested deeper than this collection allows.", @@ -28,6 +30,7 @@ const STATUS: Record = { hasContent: 409, invalidInput: 400, invalidMetadata: 400, + invalidPolicy: 400, invalidPosition: 400, limitReached: 409, maxDepth: 400, diff --git a/packages/vitnode/src/content/containers/policies.test.ts b/packages/vitnode/src/content/containers/policies.test.ts new file mode 100644 index 0000000000..aa20b9bb24 --- /dev/null +++ b/packages/vitnode/src/content/containers/policies.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from "vitest"; + +import type { + ContentContainerPolicy, + ContentContainerPolicyOverrides, + ResolvedContentContainerPolicies, +} from "./policies"; + +import { ContentEngineError } from "../errors"; +import { + contentAuthorMayEdit, + contentContainerPolicyFallback, + DISABLED_CONTENT_CONTAINER_POLICIES, + readContentContainerPolicyOverrides, + resolveContentContainerPolicies, + resolveContentContainerPolicy, +} from "./policies"; + +const REPLY = "forum.reply"; +const TOPIC = "forum.topic"; + +const FALLBACK: ContentContainerPolicy = { + authorEdit: "always", + authorEditMinutes: 60, + create: "members", + edits: "immediate", + publish: "immediate", +}; + +describe("resolveContentContainerPolicies", () => { + it.each([undefined, false] as const)( + "turns policies off when they are %s", + policies => { + expect(resolveContentContainerPolicies("forum.forums", policies)).toBe( + DISABLED_CONTENT_CONTAINER_POLICIES, + ); + }, + ); + + it("turns policies on with nothing decided for an empty object", () => { + expect(resolveContentContainerPolicies("forum.forums", {})).toEqual({ + contentTypes: {}, + defaults: {}, + enabled: true, + }); + }); + + it("keeps the declared values and never stores an undefined one", () => { + const resolved = resolveContentContainerPolicies("forum.forums", { + contentTypes: { + [REPLY]: { authorEdit: "window", authorEditMinutes: 15 }, + }, + defaults: { edits: undefined, publish: "approve" }, + }); + + expect(resolved).toEqual({ + contentTypes: { + [REPLY]: { authorEdit: "window", authorEditMinutes: 15 }, + }, + defaults: { publish: "approve" }, + enabled: true, + }); + expect(Object.hasOwn(resolved.defaults, "edits")).toBe(false); + }); + + it.each<[string, unknown, RegExp]>([ + ["an array", [], /policies is an object/], + ["a string", "approve", /policies is an object/], + ["an unknown option", { rules: {} }, /policies\.rules is not an option/], + [ + "a content type key that is not an id", + { contentTypes: { Reply: {} } }, + /has the key "Reply", which is not a content type id/, + ], + [ + "an unknown policy key in defaults", + { defaults: { moderate: true } }, + /policies\.defaults\.moderate is not a policy/, + ], + [ + "an unknown policy key for a content type", + { contentTypes: { [REPLY]: { approve: true } } }, + /policies\.contentTypes\["forum\.reply"\]\.approve is not a policy/, + ], + [ + "an unknown publish mode", + { defaults: { publish: "later" } }, + /policies\.defaults\.publish is not valid/, + ], + [ + "an unknown create audience", + { defaults: { create: "everyone" } }, + /policies\.defaults\.create is not valid/, + ], + [ + "an unknown authorEdit", + { contentTypes: { [REPLY]: { authorEdit: "sometimes" } } }, + /policies\.contentTypes\["forum\.reply"\]\.authorEdit is not valid/, + ], + [ + "a zero-minute window", + { defaults: { authorEditMinutes: 0 } }, + /policies\.defaults\.authorEditMinutes is not valid/, + ], + [ + "a fractional window", + { defaults: { authorEditMinutes: 1.5 } }, + /policies\.defaults\.authorEditMinutes is not valid/, + ], + [ + "a window longer than a year", + { defaults: { authorEditMinutes: 60 * 24 * 365 + 1 } }, + /policies\.defaults\.authorEditMinutes is not valid/, + ], + ])("rejects %s", (_label, policies, message) => { + const resolve = () => + resolveContentContainerPolicies("forum.forums", policies as never); + + expect(resolve).toThrow(ContentEngineError); + expect(resolve).toThrow(message); + }); +}); + +describe("contentContainerPolicyFallback", () => { + it.each([ + ["published", "immediate"], + ["draft", "approve"], + ] as const)( + "publishes a %s create %s, and lets owners edit forever", + (status, publish) => { + expect( + contentContainerPolicyFallback({ + publicWrite: { create: { status } }, + } as never), + ).toEqual({ ...FALLBACK, publish }); + }, + ); +}); + +describe("resolveContentContainerPolicy", () => { + const enabled = ( + policies: Partial = {}, + ): ResolvedContentContainerPolicies => ({ + contentTypes: {}, + defaults: {}, + enabled: true, + ...policies, + }); + + const resolve = ( + chain: { id: number; policies: ContentContainerPolicyOverrides }[], + policies = enabled(), + contentTypeId = REPLY, + ) => + resolveContentContainerPolicy({ + chain, + contentTypeId, + fallback: FALLBACK, + policies, + }); + + it("is the fallback when nothing decides", () => { + expect(resolve([{ id: 1, policies: {} }])).toEqual({ + policy: FALLBACK, + sources: { + authorEdit: { kind: "fallback" }, + authorEditMinutes: { kind: "fallback" }, + create: { kind: "fallback" }, + edits: { kind: "fallback" }, + publish: { kind: "fallback" }, + }, + }); + }); + + it("decides every key on its own, nearest layer first, and says where each came from", () => { + const effective = resolve( + [ + { id: 3, policies: { [REPLY]: { create: "moderators" } } }, + { id: 2, policies: { [REPLY]: { publish: "approve" } } }, + { + id: 1, + policies: { [REPLY]: { edits: "approve", publish: "immediate" } }, + }, + ], + enabled({ + contentTypes: { [REPLY]: { authorEdit: "window", edits: "immediate" } }, + defaults: { authorEdit: "never", authorEditMinutes: 30 }, + }), + ); + + expect(effective).toEqual({ + policy: { + authorEdit: "window", + authorEditMinutes: 30, + create: "moderators", + edits: "approve", + publish: "approve", + }, + sources: { + authorEdit: { kind: "contentType" }, + authorEditMinutes: { kind: "default" }, + create: { containerId: 3, kind: "container" }, + edits: { containerId: 1, kind: "container" }, + publish: { containerId: 2, kind: "container" }, + }, + }); + }); + + it("treats an explicit `never` as an override, not as nothing", () => { + const effective = resolve( + [ + { id: 2, policies: { [REPLY]: { authorEdit: "never" } } }, + { id: 1, policies: { [REPLY]: { authorEdit: "window" } } }, + ], + enabled({ defaults: { authorEdit: "always" } }), + ); + + expect(effective.policy.authorEdit).toBe("never"); + expect(effective.sources.authorEdit).toEqual({ + containerId: 2, + kind: "container", + }); + }); + + it("inherits a key the container leaves absent or undefined", () => { + const effective = resolve([ + { id: 2, policies: { [REPLY]: { publish: undefined } } }, + { id: 1, policies: { [REPLY]: { publish: "approve" } } }, + ]); + + expect(effective.policy.publish).toBe("approve"); + expect(effective.sources.publish).toEqual({ + containerId: 1, + kind: "container", + }); + }); + + it("keeps the overrides of different content types in one container apart", () => { + const chain = [ + { + id: 1, + policies: { + [REPLY]: { authorEdit: "window" }, + [TOPIC]: { publish: "approve" }, + } satisfies ContentContainerPolicyOverrides, + }, + ]; + const policies = enabled({ + contentTypes: { [TOPIC]: { create: "moderators" } }, + }); + + expect(resolve(chain, policies, TOPIC).policy).toEqual({ + ...FALLBACK, + create: "moderators", + publish: "approve", + }); + expect(resolve(chain, policies, REPLY).policy).toEqual({ + ...FALLBACK, + authorEdit: "window", + }); + }); +}); + +describe("contentAuthorMayEdit", () => { + const NOW = new Date("2026-10-13T12:00:00.000Z"); + const ago = (milliseconds: number) => new Date(NOW.getTime() - milliseconds); + const timed = { authorEdit: "window", authorEditMinutes: 60 } as const; + + it.each<[string, Parameters, boolean]>([ + [ + "always, whenever it was created", + [{ ...timed, authorEdit: "always" }, null, NOW], + true, + ], + [ + "never, even right after creating it", + [{ ...timed, authorEdit: "never" }, NOW, NOW], + false, + ], + ["inside the window", [timed, ago(60 * 60_000 - 1), NOW], true], + ["the moment the window closes", [timed, ago(60 * 60_000), NOW], false], + ["after the window", [timed, ago(61 * 60_000), NOW], false], + ["a window with no createdAt", [timed, null, NOW], false], + ["a window with an undefined createdAt", [timed, undefined, NOW], false], + ])("%s", (_label, args, expected) => { + expect(contentAuthorMayEdit(...args)).toBe(expected); + }); +}); + +describe("readContentContainerPolicyOverrides", () => { + it.each([null, undefined, "x", 5, [{ publish: "approve" }]])( + "reads %j as no overrides", + value => { + expect(readContentContainerPolicyOverrides(value)).toEqual({}); + }, + ); + + it("keeps valid entries and drops the invalid ones", () => { + expect( + readContentContainerPolicyOverrides({ + [REPLY]: { authorEdit: "window", authorEditMinutes: 10 }, + [TOPIC]: { publish: "approve" }, + "forum.legacy": { publish: "later" }, + "forum.renamed": { moderate: true }, + "forum.broken": "approve", + }), + ).toEqual({ + [REPLY]: { authorEdit: "window", authorEditMinutes: 10 }, + [TOPIC]: { publish: "approve" }, + }); + }); +}); diff --git a/packages/vitnode/src/content/containers/policies.ts b/packages/vitnode/src/content/containers/policies.ts new file mode 100644 index 0000000000..e0fa05f871 --- /dev/null +++ b/packages/vitnode/src/content/containers/policies.ts @@ -0,0 +1,263 @@ +import { z } from "zod"; + +import type { AnyContentTypeDefinition } from "../types"; + +import { CONTENT_ID_PATTERN } from "../const"; +import { ContentEngineError } from "../errors"; +import { + CONTENT_CONTAINER_POLICY_AUTHOR_EDIT, + CONTENT_CONTAINER_POLICY_CREATE, + CONTENT_CONTAINER_POLICY_KEYS, + CONTENT_CONTAINER_POLICY_MAX_MINUTES, + CONTENT_CONTAINER_POLICY_MODES, +} from "./const"; + +export type ContentContainerPolicyCreate = + (typeof CONTENT_CONTAINER_POLICY_CREATE)[number]; + +export type ContentContainerPolicyMode = + (typeof CONTENT_CONTAINER_POLICY_MODES)[number]; + +export type ContentContainerPolicyAuthorEdit = + (typeof CONTENT_CONTAINER_POLICY_AUTHOR_EDIT)[number]; + +export type ContentContainerPolicyKey = + (typeof CONTENT_CONTAINER_POLICY_KEYS)[number]; + +export interface ContentContainerPolicy { + authorEdit: ContentContainerPolicyAuthorEdit; + authorEditMinutes: number; + create: ContentContainerPolicyCreate; + edits: ContentContainerPolicyMode; + publish: ContentContainerPolicyMode; +} + +export type ContentContainerPolicyInput = Partial; + +/** Per content type id, what one container overrides. A missing key inherits. */ +export type ContentContainerPolicyOverrides = Record< + string, + ContentContainerPolicyInput +>; + +export interface ContentContainerPoliciesInput { + contentTypes?: Record; + defaults?: ContentContainerPolicyInput; +} + +export interface ResolvedContentContainerPolicies { + contentTypes: Record; + defaults: ContentContainerPolicyInput; + enabled: boolean; +} + +export type ContentContainerPolicySource = + | { containerId: number; kind: "container" } + | { kind: "contentType" } + | { kind: "default" } + | { kind: "fallback" }; + +export interface EffectiveContentContainerPolicy { + policy: ContentContainerPolicy; + sources: Record; +} + +export const DISABLED_CONTENT_CONTAINER_POLICIES: ResolvedContentContainerPolicies = + { contentTypes: {}, defaults: {}, enabled: false }; + +export const zodContentContainerPolicy = z.strictObject({ + authorEdit: z.enum(CONTENT_CONTAINER_POLICY_AUTHOR_EDIT).optional(), + authorEditMinutes: z + .number() + .int() + .min(1) + .max(CONTENT_CONTAINER_POLICY_MAX_MINUTES) + .optional(), + create: z.enum(CONTENT_CONTAINER_POLICY_CREATE).optional(), + edits: z.enum(CONTENT_CONTAINER_POLICY_MODES).optional(), + publish: z.enum(CONTENT_CONTAINER_POLICY_MODES).optional(), +}); + +export const zodContentContainerPolicyOverrides = z.record( + z.string().regex(CONTENT_ID_PATTERN), + zodContentContainerPolicy, +); + +export const zodEffectiveContentContainerPolicy = z.object({ + authorEdit: z.enum(CONTENT_CONTAINER_POLICY_AUTHOR_EDIT), + authorEditMinutes: z.number(), + create: z.enum(CONTENT_CONTAINER_POLICY_CREATE), + edits: z.enum(CONTENT_CONTAINER_POLICY_MODES), + publish: z.enum(CONTENT_CONTAINER_POLICY_MODES), +}); + +const compact = ( + value: ContentContainerPolicyInput, +): ContentContainerPolicyInput => + Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined), + ); + +const parsePolicy = ( + id: string, + label: string, + value: unknown, +): ContentContainerPolicyInput => { + const parsed = zodContentContainerPolicy.safeParse(value); + if (parsed.success) return compact(parsed.data); + + const issue = parsed.error.issues[0]; + const key = issue?.path[0]; + throw new ContentEngineError( + issue?.code === "unrecognized_keys" + ? `${label}.${issue.keys[0]} is not a policy. A policy accepts: ${CONTENT_CONTAINER_POLICY_KEYS.join(", ")}.` + : `${label}${typeof key === "string" ? `.${key}` : ""} is not valid: ${issue?.message ?? "unknown value"}.`, + { contentTypeId: id }, + ); +}; + +export const resolveContentContainerPolicies = ( + id: string, + policies: ContentContainerPoliciesInput | false | undefined, +): ResolvedContentContainerPolicies => { + if (policies === undefined || policies === false) { + return DISABLED_CONTENT_CONTAINER_POLICIES; + } + + if (typeof policies !== "object" || Array.isArray(policies)) { + throw new ContentEngineError( + "policies is an object with `defaults` and `contentTypes`, or `false`.", + { contentTypeId: id }, + ); + } + + const unknown = Object.keys(policies).find( + key => key !== "defaults" && key !== "contentTypes", + ); + if (unknown !== undefined) { + throw new ContentEngineError( + `policies.${unknown} is not an option. policies accepts: defaults, contentTypes.`, + { contentTypeId: id }, + ); + } + + const contentTypes: Record = {}; + for (const [contentTypeId, value] of Object.entries( + policies.contentTypes ?? {}, + )) { + if (!CONTENT_ID_PATTERN.test(contentTypeId)) { + throw new ContentEngineError( + `policies.contentTypes has the key "${contentTypeId}", which is not a content type id like "plugin.entity".`, + { contentTypeId: id }, + ); + } + contentTypes[contentTypeId] = parsePolicy( + id, + `policies.contentTypes["${contentTypeId}"]`, + value, + ); + } + + return { + contentTypes, + defaults: parsePolicy(id, "policies.defaults", policies.defaults ?? {}), + enabled: true, + }; +}; + +/** + * What a content type does when nothing above it decides: its own + * `publicWrite.create.status`, owners editing for as long as they like. + */ +export const contentContainerPolicyFallback = ( + definition: Pick, +): ContentContainerPolicy => ({ + authorEdit: "always", + authorEditMinutes: 60, + create: "members", + edits: "immediate", + publish: + definition.publicWrite.create.status === "published" + ? "immediate" + : "approve", +}); + +/** + * The effective policy of one content type in one container. + * + * `chain` is the container followed by its ancestors, nearest first. Every key + * is decided on its own: the first container in the chain that sets it, then + * the definition's per-type default, its general default and the fallback. + */ +export const resolveContentContainerPolicy = ({ + chain, + contentTypeId, + fallback, + policies, +}: { + chain: readonly { id: number; policies: ContentContainerPolicyOverrides }[]; + contentTypeId: string; + fallback: ContentContainerPolicy; + policies: ResolvedContentContainerPolicies; +}): EffectiveContentContainerPolicy => { + const policy = { ...fallback }; + const sources = Object.fromEntries( + CONTENT_CONTAINER_POLICY_KEYS.map(key => [key, { kind: "fallback" }]), + ) as EffectiveContentContainerPolicy["sources"]; + + const layers: [ContentContainerPolicyInput, ContentContainerPolicySource][] = + [ + ...chain.map( + container => + [ + container.policies[contentTypeId] ?? {}, + { containerId: container.id, kind: "container" }, + ] as [ContentContainerPolicyInput, ContentContainerPolicySource], + ), + [policies.contentTypes[contentTypeId] ?? {}, { kind: "contentType" }], + [policies.defaults, { kind: "default" }], + ]; + + for (const key of CONTENT_CONTAINER_POLICY_KEYS) { + for (const [layer, source] of layers) { + if (!Object.hasOwn(layer, key) || layer[key] === undefined) continue; + (policy as Record)[key] = layer[key]; + sources[key] = source; + break; + } + } + + return { policy, sources }; +}; + +/** Whether an owner may still edit a record created at `createdAt`. */ +export const contentAuthorMayEdit = ( + policy: Pick, + createdAt: Date | null | undefined, + now: Date = new Date(), +): boolean => { + if (policy.authorEdit === "always") return true; + if (policy.authorEdit === "never") return false; + if (!(createdAt instanceof Date)) return false; + + return ( + now.getTime() - createdAt.getTime() < policy.authorEditMinutes * 60_000 + ); +}; + +/** Reads a stored `policies` column defensively: an entry that no longer parses is dropped whole. */ +export const readContentContainerPolicyOverrides = ( + value: unknown, +): ContentContainerPolicyOverrides => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + + const overrides: ContentContainerPolicyOverrides = {}; + for (const [contentTypeId, entry] of Object.entries(value)) { + const parsed = zodContentContainerPolicy.safeParse(entry); + if (parsed.success) overrides[contentTypeId] = compact(parsed.data); + } + + return overrides; +}; diff --git a/packages/vitnode/src/content/containers/registry.ts b/packages/vitnode/src/content/containers/registry.ts index 50f5b33ff1..1a5140a965 100644 --- a/packages/vitnode/src/content/containers/registry.ts +++ b/packages/vitnode/src/content/containers/registry.ts @@ -4,7 +4,10 @@ import type { AnyContentContainerDefinition } from "./types"; import { CONTENT_PERMISSIONS } from "../const"; import { ContentEngineError } from "../errors"; -import { contentContainerFieldOf } from "./association"; +import { + contentContainerFieldOf, + contentContainerSourceOf, +} from "./association"; import { isContentContainerDefinition } from "./guard"; export interface RegisteredContentContainer { @@ -115,6 +118,14 @@ export const assertContentContainerReferences = ( ); for (const definition of contentTypes) { + const source = contentContainerSourceOf(definition); + if (source?.kind === "relation" && !source.container) { + throw new ContentEngineError( + `Field "${source.name}" is \`container: true\`, but its target ${source.target ? `"${source.target.id}"` : "could not be resolved and"} is not filed in a container. Point it at a content type with a \`field.container()\`.`, + { contentTypeId: definition.id }, + ); + } + const container = contentContainerFieldOf(definition); if (!container) continue; diff --git a/packages/vitnode/src/content/containers/server/admin-routes.test.ts b/packages/vitnode/src/content/containers/server/admin-routes.test.ts index 9c6ba8d1c9..17eec6b55c 100644 --- a/packages/vitnode/src/content/containers/server/admin-routes.test.ts +++ b/packages/vitnode/src/content/containers/server/admin-routes.test.ts @@ -8,7 +8,15 @@ import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; import { core_content_containers } from "@/database/content-containers"; import { createTestCache } from "@/tests/cache"; -import { testBoardContainers } from "@/tests/content-fixtures"; +import { + testBoardContainers, + testPolicyBoardContainers, +} from "@/tests/content-fixtures"; +import { + POLICY_PLUGIN_ID, + policyReplies, + policyTopics, +} from "@/tests/content-policies"; import { createMemoryDb } from "@/tests/memory-db"; import { grantStaffPermissions, @@ -290,3 +298,198 @@ describe("permission matrix", () => { }, ); }); + +describe("publishing policies", () => { + const POLICY_MODULE = testPolicyBoardContainers.admin.permissionModule; + const TOPIC = policyTopics.definition.id; + const REPLY = policyReplies.definition.id; + const PARENT = 1; + const CHILD = 2; + + const policyRoutes = buildContentContainerAdminRoutes( + testPolicyBoardContainers, + { pluginId: POLICY_PLUGIN_ID, usage: holdsContent }, + ); + + const policyHarness = async ( + permissions: + | PermissionsStaffArgs[] + | typeof ROOT_STAFF_PERMISSIONS = ROOT_STAFF_PERMISSIONS, + ) => { + const memory = createMemoryDb([ + [ + core_content_containers, + [ + { + ...row(PARENT, "parent", null, 0, testPolicyBoardContainers.id), + pluginId: POLICY_PLUGIN_ID, + policies: { [TOPIC]: { publish: "approve" } }, + }, + { + ...row(CHILD, "child", PARENT, 0, testPolicyBoardContainers.id), + pluginId: POLICY_PLUGIN_ID, + policies: { [TOPIC]: { authorEdit: "never" } }, + }, + ], + ], + ]); + const cache = createTestCache(); + await grantStaffPermissions(cache, { permissions, userId: adminUser.id }); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: adminUser } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { + emit: async () => await Promise.resolve({ failures: [] }), + } as unknown as Context["var"]["events"]); + c.set("core", { + contentModels: [ + { model: policyTopics, pluginId: POLICY_PLUGIN_ID }, + { model: policyReplies, pluginId: POLICY_PLUGIN_ID }, + ], + } as unknown as Context["var"]["core"]); + await next(); + }); + app.onError((error, c) => + error instanceof HTTPException + ? error.getResponse() + : c.json({ error: String(error) }, 500), + ); + for (const { handler, hook, route } of policyRoutes) { + app.openapi(route, handler, hook as never); + } + + const send = async (method: string, path: string, body?: unknown) => + await app.request(path, { + method, + ...(body === undefined + ? {} + : { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }), + }); + + return { rows: () => memory.rows(core_content_containers), send }; + }; + + const grantOf = (permissions: readonly string[]): PermissionsStaffArgs[] => + permissions.map(permission => ({ + module: POLICY_MODULE, + permission, + plugin: POLICY_PLUGIN_ID, + })); + + it.each([ + ["GET", CONTENT_PERMISSIONS.view, undefined], + ["PUT", CONTENT_PERMISSIONS.edit, { overrides: {} }], + ])( + "%s /{id}/policies is refused without %s and allowed with only it", + async (method, permission, body) => { + const statusWith = async (grant: PermissionsStaffArgs[]) => { + const { send } = await policyHarness(grant); + + return (await send(method, `/${CHILD}/policies`, body)).status; + }; + + await expect(statusWith([])).resolves.toBe(403); + await expect( + statusWith( + grantOf(ALL_PERMISSIONS.filter(other => other !== permission)), + ), + ).resolves.toBe(403); + await expect(statusWith(grantOf([permission]))).resolves.toBe(200); + }, + ); + + it("reports the container's overrides, what it inherits and where each value comes from", async () => { + const { send } = await policyHarness(); + + const response = await send("GET", `/${CHILD}/policies`); + + expect(response.status).toBe(200); + const report = (await response.json()) as { + contentTypes: { contentTypeId: string }[]; + overrides: unknown; + }; + expect(report.overrides).toEqual({ [TOPIC]: { authorEdit: "never" } }); + expect(report.contentTypes).toEqual([ + { + contentTypeId: TOPIC, + effective: { + authorEdit: "never", + authorEditMinutes: 60, + create: "members", + edits: "immediate", + publish: "approve", + }, + inherited: { + authorEdit: "always", + authorEditMinutes: 60, + create: "members", + edits: "immediate", + publish: "approve", + }, + pluginId: POLICY_PLUGIN_ID, + sources: { + authorEdit: { containerId: CHILD, kind: "container" }, + authorEditMinutes: { kind: "fallback" }, + create: { kind: "fallback" }, + edits: { kind: "default" }, + publish: { containerId: PARENT, kind: "container" }, + }, + }, + expect.objectContaining({ + contentTypeId: REPLY, + effective: expect.objectContaining({ + authorEdit: "window", + publish: "immediate", + }), + sources: expect.objectContaining({ + authorEdit: { kind: "contentType" }, + publish: { kind: "default" }, + }), + }), + ]); + }); + + it("replaces the overrides", async () => { + const { rows, send } = await policyHarness(); + const overrides = { [REPLY]: { publish: "approve" } }; + + const response = await send("PUT", `/${CHILD}/policies`, { overrides }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ overrides }); + expect(rows().find(candidate => candidate.id === CHILD)?.policies).toEqual( + overrides, + ); + }); + + it.each([ + [ + "a content type not filed in the collection", + { "test.topic": { publish: "approve" } }, + "invalidPolicy", + ], + ["an unknown value", { [TOPIC]: { publish: "sometimes" } }, "invalidInput"], + ["an unknown key", { [TOPIC]: { moderated: true } }, "invalidInput"], + ])( + "refuses %s with 400 and keeps the overrides", + async (_label, overrides, code) => { + const { rows, send } = await policyHarness(); + const before = rows(); + + const response = await send("PUT", `/${CHILD}/policies`, { overrides }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + code, + message: expect.any(String), + }); + expect(rows()).toEqual(before); + }, + ); +}); diff --git a/packages/vitnode/src/content/containers/server/admin-routes.ts b/packages/vitnode/src/content/containers/server/admin-routes.ts index 57744dee97..4291585fe8 100644 --- a/packages/vitnode/src/content/containers/server/admin-routes.ts +++ b/packages/vitnode/src/content/containers/server/admin-routes.ts @@ -17,6 +17,10 @@ import { buildRoute } from "../../../api/lib/route"; import { CONTENT_PERMISSIONS } from "../../const"; import { CONTENT_CONTAINER_ERROR_CODES } from "../const"; import { ContentContainerError } from "../errors"; +import { + zodContentContainerPolicyOverrides, + zodEffectiveContentContainerPolicy, +} from "../policies"; import { contentContainerInputRefusal, createContentContainerService, @@ -46,6 +50,25 @@ const validationHook: RouteValidationHook = (result, c) => { return c.json(contentContainerInputRefusal(result.error), 400); }; +const zodPolicySource = z.union([ + z.object({ containerId: z.number(), kind: z.literal("container") }), + z.object({ kind: z.enum(["contentType", "default", "fallback"]) }), +]); + +export const zodContentContainerPolicyReport = z.object({ + contentTypes: z.array( + z.object({ + contentTypeId: z.string(), + effective: zodEffectiveContentContainerPolicy, + inherited: zodEffectiveContentContainerPolicy, + pluginId: z.string(), + sources: z.record(z.string(), zodPolicySource), + }), + ), + id: z.number(), + overrides: zodContentContainerPolicyOverrides, +}); + const idParams = z.object({ id: z.coerce.number().int().positive(), }); @@ -229,5 +252,85 @@ export const buildContentContainerAdminRoutes = < }, }); - return [list, create, update, move, remove]; + if (!definition.policies.enabled) { + return [list, create, update, move, remove]; + } + + const policies = buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}/policies", + description: `Read a container's publishing policies in ${definition.id}: its own overrides and what applies to each content type (Admin only)`, + request: { params: idParams }, + responses: { + 200: { + content: { + "application/json": { schema: zodContentContainerPolicyReport }, + }, + description: "Overrides and effective policies", + }, + 403: { description: "Access Denied" }, + 404: errorResponse("No such container in this collection"), + }, + }, + handler: async c => { + try { + const { id } = c.req.valid("param"); + + return c.json(await service(c).policies(id), 200); + } catch (error) { + return containerError(error); + } + }, + }); + + const updatePolicies = buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + route: { + method: "put", + path: "/{id}/policies", + description: `Replace a container's publishing policy overrides in ${definition.id}. A key left out inherits from the parent container and the definition (Admin only)`, + request: { + params: idParams, + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + overrides: zodContentContainerPolicyOverrides, + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: zodContentContainerPolicyReport }, + }, + description: "Overrides saved", + }, + 400: errorResponse("An unknown content type or an invalid value"), + 403: { description: "Access Denied" }, + 404: errorResponse("No such container in this collection"), + }, + }, + handler: async c => { + try { + const { id } = c.req.valid("param"); + const { overrides } = c.req.valid("json"); + + return c.json(await service(c).updatePolicies(id, overrides), 200); + } catch (error) { + return containerError(error); + } + }, + }); + + return [list, create, update, move, remove, policies, updatePolicies]; }; diff --git a/packages/vitnode/src/content/containers/server/policy.test.ts b/packages/vitnode/src/content/containers/server/policy.test.ts new file mode 100644 index 0000000000..8f31dd28f4 --- /dev/null +++ b/packages/vitnode/src/content/containers/server/policy.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { core_content_containers } from "@/database/content-containers"; +import { createMemoryDb } from "@/tests/memory-db"; + +import type { ContentDatabase } from "../../server/service"; + +import { + contentContainerScopeResources, + expandContentContainerScope, +} from "./policy"; + +describe("contentContainerScopeResources", () => { + it.each<[string, { id: number }[], string[]]>([ + ["no container", [], []], + ["a top-level container", [{ id: 5 }], ["5", "5:tree"]], + // Ancestors only as subtrees: a bare id never reaches a descendant. + [ + "a nested container, with the subtree of every ancestor", + [{ id: 5 }, { id: 3 }, { id: 1 }], + ["5", "5:tree", "3:tree", "1:tree"], + ], + ])("covers %s", (_label, chain, expected) => { + expect(contentContainerScopeResources(chain)).toEqual(expected); + }); +}); + +describe("expandContentContainerScope", () => { + const CREATED = new Date("2026-09-01T10:00:00.000Z"); + const row = ( + id: number, + definitionId: string, + parentId: null | number = null, + ) => ({ + createdAt: CREATED, + definitionId, + id, + metadata: {}, + name: `Container ${id}`, + parentId, + pluginId: "@vitnode/forum", + policies: {}, + position: 0, + slug: `container-${id}`, + updatedAt: CREATED, + }); + + // 1 ─┬─ 2 ── 3 5 (forum.forums) + // └─ 4 + // 10 ── 11 (forum.sections) + const db = createMemoryDb([ + [ + core_content_containers, + [ + row(1, "forum.forums"), + row(2, "forum.forums", 1), + row(3, "forum.forums", 2), + row(4, "forum.forums", 1), + row(5, "forum.forums"), + row(10, "forum.sections"), + row(11, "forum.sections", 10), + ], + ], + ]).db as unknown as ContentDatabase; + + it("passes `all` through", async () => { + await expect( + expandContentContainerScope(db, "forum.forums", "all"), + ).resolves.toBe("all"); + }); + + it.each<[string, string[], number[]]>([ + ["nothing", [], []], + ["bare ids, without their descendants", ["2", "5"], [2, 5]], + ["a subtree", ["2:tree"], [2, 3]], + ["a subtree from the root", ["1:tree"], [1, 2, 3, 4]], + ["ids and subtrees together, once each", ["3", "2:tree", "5"], [2, 3, 5]], + ["a subtree that does not exist", ["99:tree"], []], + ["a subtree in another collection", ["10:tree"], []], + [ + "resource ids that are not container ids", + ["", "abc", "0", "-1", "1.5", ":tree", "abc:tree"], + [], + ], + ])("expands %s", async (_label, resources, expected) => { + await expect( + expandContentContainerScope(db, "forum.forums", resources), + ).resolves.toEqual(expected); + }); +}); diff --git a/packages/vitnode/src/content/containers/server/policy.ts b/packages/vitnode/src/content/containers/server/policy.ts new file mode 100644 index 0000000000..f34f8bbf71 --- /dev/null +++ b/packages/vitnode/src/content/containers/server/policy.ts @@ -0,0 +1,350 @@ +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { eq, inArray } from "drizzle-orm"; + +import type { ContentId } from "../../ids"; +import type { ContentDatabase } from "../../server/service"; +import type { AnyContentTypeDefinition } from "../../types"; +import type { + ContentContainerPolicyOverrides, + EffectiveContentContainerPolicy, +} from "../policies"; +import type { AnyContentContainerDefinition } from "../types"; +import type { ResolvedContentContainerContext } from "./assignment"; + +import { resolveStaffPermissions } from "../../../api/lib/check-staff-permission"; +import { hasStaffPermission } from "../../../api/lib/staff-permission"; +import { core_content_containers } from "../../../database/content-containers"; +import { + ContentContainerAssignmentError, + contentContainerSourceOf, +} from "../association"; +import { + CONTENT_CONTAINER_ABSOLUTE_MAX_DEPTH, + CONTENT_CONTAINER_TREE_SUFFIX, +} from "../const"; +import { + contentContainerPolicyFallback, + readContentContainerPolicyOverrides, + resolveContentContainerPolicy, +} from "../policies"; +import { resolveContentContainerAssignment } from "./assignment"; + +export interface ContentContainerChainEntry { + definitionId: string; + id: number; + parentId: null | number; + pluginId: string; + policies: ContentContainerPolicyOverrides; + slug: string; +} + +/** + * Where a record lives, as the server resolved it - never what a client sent. + * `chain` is the container followed by its ancestors, nearest first. + */ +export interface ContentRecordContainer { + chain: ContentContainerChainEntry[]; + container: ResolvedContentContainerContext; + definition: AnyContentContainerDefinition; +} + +const chainSelection = { + definitionId: core_content_containers.definitionId, + id: core_content_containers.id, + parentId: core_content_containers.parentId, + pluginId: core_content_containers.pluginId, + policies: core_content_containers.policies, + slug: core_content_containers.slug, +}; + +export const loadContentContainerChain = async ( + db: ContentDatabase, + containerId: number, +): Promise => { + const chain: ContentContainerChainEntry[] = []; + const seen = new Set(); + let next: null | number = containerId; + + while ( + next !== null && + !seen.has(next) && + chain.length < CONTENT_CONTAINER_ABSOLUTE_MAX_DEPTH + ) { + seen.add(next); + const [row] = await db + .select(chainSelection) + .from(core_content_containers) + .where(eq(core_content_containers.id, next)) + .limit(1); + if (!row) break; + + chain.push({ + ...row, + policies: readContentContainerPolicyOverrides(row.policies), + }); + next = row.parentId; + } + + return chain; +}; + +const modelOf = (c: Context, contentTypeId: string) => + (c.get("core")?.contentModels ?? []).find( + entry => entry.model.definition.id === contentTypeId, + )?.model; + +/** + * The container id a related record is filed in, for a content type whose + * container comes from `field.relation({ container: true })`. + */ +const containerOfRelated = async ( + c: Context, + db: ContentDatabase, + definition: AnyContentTypeDefinition, + relatedId: unknown, +): Promise => { + const source = contentContainerSourceOf(definition); + if (source?.kind !== "relation" || !source.target) return null; + if (relatedId === null || relatedId === undefined) return null; + + const target = source.target; + const targetSource = contentContainerSourceOf(target); + const model = modelOf(c, target.id); + if (!model || targetSource?.kind !== "field") { + throw new ContentContainerAssignmentError({ + code: "CONTAINER_NOT_FOUND", + contentTypeId: definition.id, + field: source.name, + }); + } + + const columns = model.columns as Record; + const table: PgTable = model.table; + const [row] = await db + .select({ value: columns[targetSource.name] }) + .from(table) + .where(eq(columns.id, relatedId as ContentId)) + .limit(1); + const value: unknown = row?.value; + + return typeof value === "number" ? value : null; +}; + +const toContext = async ( + db: ContentDatabase, + definition: AnyContentContainerDefinition, + containerId: null | number, +): Promise => { + if (containerId === null) return null; + const chain = await loadContentContainerChain(db, containerId); + const [self] = chain; + if (self?.definitionId !== definition.id) return null; + + return { + chain, + container: { + definitionId: self.definitionId, + id: self.id, + parentId: self.parentId, + pluginId: self.pluginId, + slug: self.slug, + }, + definition, + }; +}; + +/** + * The container a new record would be filed in, from the values being written. + * A direct container field is validated as an assignment; an inherited one is + * read from the related record, whatever the payload claims. + */ +export const resolveContentWriteContainer = async ( + c: Context, + db: ContentDatabase, + definition: AnyContentTypeDefinition, + values: Record, +): Promise => { + const source = contentContainerSourceOf(definition); + if (!source?.container) return null; + + if (source.kind === "field") { + const assigned = await resolveContentContainerAssignment( + db, + definition, + values, + ); + + return assigned ? await toContext(db, source.container, assigned.id) : null; + } + + return await toContext( + db, + source.container, + await containerOfRelated(c, db, definition, values[source.name]), + ); +}; + +/** The container an existing row is filed in. */ +export const resolveContentRowContainer = async ( + c: Context, + db: ContentDatabase, + definition: AnyContentTypeDefinition, + row: Record, +): Promise => { + const source = contentContainerSourceOf(definition); + if (!source?.container) return null; + + if (source.kind === "field") { + const value = row[source.name]; + + return await toContext( + db, + source.container, + typeof value === "number" ? value : null, + ); + } + + return await toContext( + db, + source.container, + await containerOfRelated(c, db, definition, row[source.name]), + ); +}; + +/** `null` when the record's container collection has no policies. */ +export const effectiveContentPolicy = ( + definition: AnyContentTypeDefinition, + container: ContentRecordContainer | null, +): EffectiveContentContainerPolicy | null => { + if (!container?.definition.policies.enabled) return null; + + return resolveContentContainerPolicy({ + chain: container.chain, + contentTypeId: definition.id, + fallback: contentContainerPolicyFallback(definition), + policies: container.definition.policies, + }); +}; + +/** + * Resource ids that cover a container: the container itself, its own subtree + * and the subtree of every ancestor. A bare id never reaches a descendant. + */ +export const contentContainerScopeResources = ( + chain: readonly Pick[], +): string[] => { + const [self, ...ancestors] = chain; + if (!self) return []; + + return [ + String(self.id), + `${self.id}${CONTENT_CONTAINER_TREE_SUFFIX}`, + ...ancestors.map(entry => `${entry.id}${CONTENT_CONTAINER_TREE_SUFFIX}`), + ]; +}; + +/** + * Whether the signed-in user holds a moderator permission of a content type for + * the record's container. Content types without container policies keep the + * unscoped check they always had. + */ +export const canModerateContent = async ( + c: Context, + { + container, + definition, + permission, + pluginId, + }: { + container: ContentRecordContainer | null; + definition: AnyContentTypeDefinition; + permission: string; + pluginId: string; + }, +): Promise => { + const user = c.get("user"); + if (!user) return false; + + const set = await resolveStaffPermissions(c, { type: "moderator", user }); + const check = { + module: definition.permissionModule, + permission, + plugin: pluginId, + }; + + if (!container?.definition.policies.enabled) { + return hasStaffPermission(set, check); + } + + return contentContainerScopeResources(container.chain).some(id => + hasStaffPermission(set, { + ...check, + resource: { family: container.definition.id, id }, + }), + ); +}; + +/** Container ids (of one collection) a set of resource ids covers. */ +export const expandContentContainerScope = async ( + db: ContentDatabase, + definitionId: string, + resources: "all" | readonly string[], +): Promise<"all" | number[]> => { + if (resources === "all") return "all"; + + const direct = new Set(); + const roots = new Set(); + for (const resource of resources) { + const tree = resource.endsWith(CONTENT_CONTAINER_TREE_SUFFIX); + const id = Number( + tree + ? resource.slice(0, -CONTENT_CONTAINER_TREE_SUFFIX.length) + : resource, + ); + if (!Number.isSafeInteger(id) || id <= 0) continue; + (tree ? roots : direct).add(id); + } + if (roots.size === 0) return [...direct].sort((a, b) => a - b); + + const rows = await db + .select({ + id: core_content_containers.id, + parentId: core_content_containers.parentId, + }) + .from(core_content_containers) + .where(eq(core_content_containers.definitionId, definitionId)); + const children = new Map(); + for (const row of rows) { + if (row.parentId === null) continue; + children.set(row.parentId, [...(children.get(row.parentId) ?? []), row.id]); + } + + const covered = new Set(direct); + const visited = new Set(); + const known = new Set(rows.map(row => row.id)); + const queue = [...roots].filter(id => known.has(id)); + while (queue.length > 0) { + const id = queue.pop(); + if (id === undefined || visited.has(id)) continue; + visited.add(id); + covered.add(id); + queue.push(...(children.get(id) ?? [])); + } + + return [...covered].sort((a, b) => a - b); +}; + +export const contentContainerIdsExist = async ( + db: ContentDatabase, + ids: readonly number[], +): Promise> => { + if (ids.length === 0) return new Set(); + const rows = await db + .select({ id: core_content_containers.id }) + .from(core_content_containers) + .where(inArray(core_content_containers.id, [...ids])); + + return new Set(rows.map(row => row.id)); +}; diff --git a/packages/vitnode/src/content/containers/server/scope.ts b/packages/vitnode/src/content/containers/server/scope.ts new file mode 100644 index 0000000000..9815002083 --- /dev/null +++ b/packages/vitnode/src/content/containers/server/scope.ts @@ -0,0 +1,89 @@ +import type { Context } from "hono"; + +import { eq } from "drizzle-orm"; + +import type { ModerationScopeDefinition } from "../../../api/lib/moderation/scopes"; +import type { AnyContentContainerDefinition } from "../types"; + +import { core_content_containers } from "../../../database/content-containers"; +import { contentContainerI18nKeys } from "../../admin/containers"; +import { CONTENT_CONTAINER_TREE_SUFFIX } from "../const"; +import { contentContainerAncestors, orderContentContainers } from "../tree"; + +interface ScopeResource { + context: string[]; + id: string; + label: string; +} + +const resourcesOf = async ( + c: Context, + definition: AnyContentContainerDefinition, +): Promise => { + const rows = await c + .get("db") + .select({ + id: core_content_containers.id, + name: core_content_containers.name, + parentId: core_content_containers.parentId, + position: core_content_containers.position, + }) + .from(core_content_containers) + .where(eq(core_content_containers.definitionId, definition.id)); + + const parents = new Set(rows.map(row => row.parentId)); + + return orderContentContainers(rows, definition.ordering.mode).flatMap(row => { + const context = contentContainerAncestors(rows, row.id) + .filter(ancestor => ancestor.id !== row.id) + .map(ancestor => ancestor.name); + const own = { context, id: String(row.id), label: row.name }; + + return parents.has(row.id) + ? [ + own, + { + context, + id: `${row.id}${CONTENT_CONTAINER_TREE_SUFFIX}`, + label: `${row.name} › *`, + }, + ] + : [own]; + }); +}; + +/** + * The moderation scope family of a container collection with policies. A + * resource is one container (`"12"`) or, for a container with children, the + * container and everything below it (`"12:tree"`). + */ +export const contentContainerModerationScope = ( + definition: AnyContentContainerDefinition, + pluginId: string, +): ModerationScopeDefinition => ({ + id: definition.id, + label: contentContainerI18nKeys(definition, pluginId).title, + find: async (c, ids) => { + const wanted = new Set(ids); + + return (await resourcesOf(c, definition)).filter(item => + wanted.has(item.id), + ); + }, + list: async (c, { cursor, limit, search }) => { + const needle = search?.toLowerCase(); + const all = (await resourcesOf(c, definition)).filter( + item => + !needle || + item.label.toLowerCase().includes(needle) || + item.context.some(name => name.toLowerCase().includes(needle)), + ); + const start = cursor ? Math.max(Number(cursor) || 0, 0) : 0; + const items = all.slice(start, start + limit); + + return { + items, + nextCursor: start + limit < all.length ? String(start + limit) : null, + }; + }, +}); diff --git a/packages/vitnode/src/content/containers/server/service.ts b/packages/vitnode/src/content/containers/server/service.ts index 4a4f96f54d..6bb8eb87b6 100644 --- a/packages/vitnode/src/content/containers/server/service.ts +++ b/packages/vitnode/src/content/containers/server/service.ts @@ -5,6 +5,12 @@ import type { z } from "zod"; import { and, eq, inArray, sql } from "drizzle-orm"; import type { ContentDatabase } from "../../server/service"; +import type { AnyContentTypeDefinition } from "../../types"; +import type { + ContentContainerPolicy, + ContentContainerPolicyOverrides, + EffectiveContentContainerPolicy, +} from "../policies"; import type { AnyContentContainerDefinition, ContentContainerCreateInput, @@ -19,13 +25,23 @@ import { core_content_containers } from "../../../database/content-containers"; import { invalidatePageCacheTags } from "../../../page-cache/store.server"; import { contentContainerTag } from "../../cache"; import { contentDefinitionOf } from "../../server/model"; +import { syncContentSearchContainerDependents } from "../../server/search-sync"; import { slugify } from "../../slug"; -import { contentContainerFieldOf } from "../association"; +import { + contentContainerFieldOf, + contentContainerSourceOf, +} from "../association"; import { CONTENT_CONTAINER_MAX_PER_DEFINITION, CONTENT_CONTAINER_SLUG_MAX_LENGTH, } from "../const"; import { ContentContainerError } from "../errors"; +import { + contentContainerPolicyFallback, + readContentContainerPolicyOverrides, + resolveContentContainerPolicy, + zodContentContainerPolicyOverrides, +} from "../policies"; import { contentContainerAncestors, contentContainerSubtreeIds, @@ -103,6 +119,7 @@ export interface ContentContainerService< id: number, input: ContentContainerMoveInput, ) => Promise>; + policies: (id: number) => Promise; toPublic: ( record: ContentContainerRecord, ) => ContentContainerPublicRecord; @@ -110,8 +127,39 @@ export interface ContentContainerService< id: number, input: ContentContainerUpdateInput, ) => Promise>; + updatePolicies: ( + id: number, + overrides: unknown, + ) => Promise; +} + +export interface ContentContainerPolicyReport { + contentTypes: { + contentTypeId: string; + effective: ContentContainerPolicy; + /** What applies once this container's own overrides are taken away. */ + inherited: ContentContainerPolicy; + pluginId: string; + sources: EffectiveContentContainerPolicy["sources"]; + }[]; + id: number; + overrides: ContentContainerPolicyOverrides; } +/** Content types filed in a container collection, directly or through a relation. */ +export const contentTypesInContainer = ( + c: Context, + definitionId: string, +): { definition: AnyContentTypeDefinition; pluginId: string }[] => + (c.get("core")?.contentModels ?? []).flatMap(({ model, pluginId }) => { + const definition = contentDefinitionOf(model); + + return contentContainerSourceOf(definition)?.container?.id === + definitionId && definition.publicWrite.enabled + ? [{ definition, pluginId }] + : []; + }); + const zodErrorMessage = (error: z.ZodError): string => error.issues .map(issue => @@ -311,6 +359,49 @@ export const createContentContainerService = < return parsed.data; }; + const policyReport = ( + rows: readonly ContainerRow[], + id: number, + ): ContentContainerPolicyReport => { + const byId = new Map(rows.map(row => [row.id, row])); + const self = byId.get(id) ?? refuse("notFound"); + const chain = [self, ...contentContainerAncestors(rows, id).reverse()].map( + row => ({ + id: row.id, + policies: readContentContainerPolicyOverrides(row.policies), + }), + ); + + return { + contentTypes: contentTypesInContainer(c, definitionId).map( + ({ definition: contentType, pluginId: owner }) => { + const input = { + contentTypeId: contentType.id, + fallback: contentContainerPolicyFallback(contentType), + policies: definition.policies, + }; + const { policy, sources } = resolveContentContainerPolicy({ + ...input, + chain, + }); + + return { + contentTypeId: contentType.id, + effective: policy, + inherited: resolveContentContainerPolicy({ + ...input, + chain: chain.slice(1), + }).policy, + pluginId: owner, + sources, + }; + }, + ), + id, + overrides: chain[0]?.policies ?? {}, + }; + }; + return { ancestors: async id => { const rows = await readAll(c.get("db")); @@ -503,6 +594,89 @@ export const createContentContainerService = < slug: record.slug, }) as ContentContainerPublicRecord, + policies: async id => { + if (!definition.policies.enabled) return refuse("invalidPolicy"); + + return policyReport(await readAll(c.get("db")), id); + }, + + updatePolicies: async (id, input) => { + if (!definition.policies.enabled) return refuse("invalidPolicy"); + + const parsed = zodContentContainerPolicyOverrides.safeParse(input); + if (!parsed.success) { + return refuse("invalidPolicy", zodErrorMessage(parsed.error)); + } + const allowed = new Set( + contentTypesInContainer(c, definitionId).map( + entry => entry.definition.id, + ), + ); + const stranger = Object.keys(parsed.data).find(key => !allowed.has(key)); + if (stranger !== undefined) { + return refuse( + "invalidPolicy", + `"${stranger}" is not a content type filed in this collection.`, + ); + } + const overrides = Object.fromEntries( + Object.entries(readContentContainerPolicyOverrides(parsed.data)).filter( + ([, value]) => Object.keys(value).length > 0, + ), + ); + + const { changed, report } = await c.get("db").transaction(async tx => { + await lock(tx); + const rows = await readAll(tx); + const row = rows.find(candidate => candidate.id === id); + if (!row) return refuse("notFound"); + + const changed = + JSON.stringify(readContentContainerPolicyOverrides(row.policies)) !== + JSON.stringify(overrides); + if (changed) { + await tx + .update(core_content_containers) + .set({ policies: overrides, updatedAt: new Date() }) + .where( + and( + eq(core_content_containers.id, id), + eq(core_content_containers.definitionId, definitionId), + ), + ); + } + + return { + changed, + report: policyReport( + rows.map(candidate => + candidate.id === id + ? { ...candidate, policies: overrides } + : candidate, + ), + id, + ), + }; + }); + + if (changed) { + await expire("soft"); + const record = await c + .get("db") + .select({ parentId: core_content_containers.parentId }) + .from(core_content_containers) + .where(eq(core_content_containers.id, id)) + .limit(1); + await emit("content.container.updated", { + changedFields: ["policies"], + containerId: id, + parentId: record[0]?.parentId ?? null, + }); + } + + return report; + }, + update: async (id, input) => { const values = parseInput(definition.schemas.update, input) as { metadata?: Record; @@ -588,6 +762,13 @@ export const createContentContainerService = < }); } + if (changedFields.includes("name") || changedFields.includes("slug")) { + await syncContentSearchContainerDependents(c, { + container: record, + containerType: definitionId, + }); + } + return record; }, }; diff --git a/packages/vitnode/src/content/containers/types.ts b/packages/vitnode/src/content/containers/types.ts index c55d97f4dd..804de540aa 100644 --- a/packages/vitnode/src/content/containers/types.ts +++ b/packages/vitnode/src/content/containers/types.ts @@ -14,6 +14,7 @@ import type { CONTENT_CONTAINER_ERROR_CODES, CONTENT_CONTAINER_ORDERING_MODES, } from "./const"; +import type { ResolvedContentContainerPolicies } from "./policies"; export type ContentContainerFieldDescriptor = | ContentBooleanField @@ -93,6 +94,7 @@ export interface ContentContainerDefinition< id: TId; kind: "contentContainer"; ordering: { mode: ContentContainerOrderingMode }; + policies: ResolvedContentContainerPolicies; publicApi: ResolvedContentContainerPublicApi< TPublicField, TPublicEnabled, diff --git a/packages/vitnode/src/content/define-fields.ts b/packages/vitnode/src/content/define-fields.ts index 2c93ba5b25..499ba17ad1 100644 --- a/packages/vitnode/src/content/define-fields.ts +++ b/packages/vitnode/src/content/define-fields.ts @@ -4,6 +4,7 @@ import type { ContentFieldDescriptor, ContentFieldMap, ContentFileField, + ContentRelationField, } from "./types"; import { @@ -49,7 +50,9 @@ const hasWritableFallback = (fieldValue: ContentFieldDescriptor): boolean => { } // A sourced slug has no column default and is not required, but it is always // writable: the service derives it from the source field. - if (fieldValue.kind === "slug") return fieldValue.source !== undefined; + if (fieldValue.kind === "slug") { + return fieldValue.source !== undefined || fieldValue.generated; + } // A single file has no default and cannot have one: a column default would be // a `core_files.id` written into the definition, pointing at a row that exists // on one installation and not on the next. `assertField` says so in words. A @@ -215,6 +218,49 @@ const assertFileField = ( } }; +const assertRelationField = ( + id: string, + name: string, + fieldValue: ContentRelationField, +): void => { + if (fieldValue.self && !fieldValue.multiple && !fieldValue.nullable) { + throw new ContentEngineError( + `Self-relation "${name}" is to-one but not nullable, so the first record could never be inserted - there is nothing for it to point at yet. Add \`nullable: true\`.`, + { contentTypeId: id }, + ); + } + + if (!fieldValue.container) return; + + if (fieldValue.multiple) { + throw new ContentEngineError( + `Relation "${name}" is \`container: true\` and \`multiple: true\`. A record lives in one container, so it can inherit it from one related record only - drop \`multiple\`.`, + { contentTypeId: id }, + ); + } + + if (fieldValue.self) { + throw new ContentEngineError( + `Relation "${name}" is \`container: true\` and \`self: true\`. A record cannot inherit its container from a record of its own type - point it at the record that is filed in the container, such as the topic a reply belongs to.`, + { contentTypeId: id }, + ); + } + + if (!fieldValue.required) { + throw new ContentEngineError( + `Relation "${name}" is \`container: true\` but not required. A record with no related record would have no container - add \`required: true\`.`, + { contentTypeId: id }, + ); + } + + if ((fieldValue as { localized?: boolean }).localized === true) { + throw new ContentEngineError( + `Relation "${name}" is \`container: true\` and \`localized: true\`. A record sits in the same container in every language.`, + { contentTypeId: id }, + ); + } +}; + const assertContainerField = ( id: string, name: string, @@ -249,10 +295,22 @@ export const assertSingleContainerField = ( const containers = Object.entries(fields).filter( ([, fieldValue]) => fieldValue.kind === "container", ); - if (containers.length <= 1) return; + if (containers.length > 1) { + throw new ContentEngineError( + `Content type declares ${containers.length} container fields (${containers.map(([name]) => name).join(", ")}). A record belongs to one container - keep one, and use a relation for anything else it points at.`, + { contentTypeId: id }, + ); + } + + const sources = Object.entries(fields).filter( + ([, fieldValue]) => + fieldValue.kind === "container" || + (fieldValue.kind === "relation" && fieldValue.container), + ); + if (sources.length <= 1) return; throw new ContentEngineError( - `Content type declares ${containers.length} container fields (${containers.map(([name]) => name).join(", ")}). A record belongs to one container - keep one, and use a relation for anything else it points at.`, + `Content type takes its container from ${sources.length} fields (${sources.map(([name]) => name).join(", ")}). A record belongs to one container - keep either one \`field.container()\` or one \`field.relation({ container: true })\`.`, { contentTypeId: id }, ); }; @@ -272,6 +330,12 @@ export const assertField = ( assertContainerField(id, name, fieldValue); } + // Before the generic writability check, which would otherwise ask a to-one + // self-relation for a default value instead of saying why it must be nullable. + if (fieldValue.kind === "relation") { + assertRelationField(id, name, fieldValue); + } + if (!fieldValue.required && !fieldValue.nullable) { if (!hasWritableFallback(fieldValue)) { throw new ContentEngineError( @@ -383,7 +447,23 @@ export const assertSlugSources = ( fields: ContentFieldMap, ): void => { for (const [name, fieldValue] of Object.entries(fields)) { - if (fieldValue.kind !== "slug" || fieldValue.source === undefined) continue; + if (fieldValue.kind !== "slug") continue; + + if (fieldValue.generated && fieldValue.source !== undefined) { + throw new ContentEngineError( + `Slug field "${name}" is both \`generated: true\` and sourced from "${fieldValue.source}". Pick one: derive it from a title, or let the server mint a random one.`, + { contentTypeId: id }, + ); + } + + if (fieldValue.generated && fieldValue.localized) { + throw new ContentEngineError( + `Slug field "${name}" is \`generated: true\` and \`localized: true\`. A generated slug is a random token with no language in it - drop \`localized\`.`, + { contentTypeId: id }, + ); + } + + if (fieldValue.source === undefined) continue; const source = fields[fieldValue.source]; if (!source) { diff --git a/packages/vitnode/src/content/define-public-api.ts b/packages/vitnode/src/content/define-public-api.ts index 2f6067c4c4..eba08971a0 100644 --- a/packages/vitnode/src/content/define-public-api.ts +++ b/packages/vitnode/src/content/define-public-api.ts @@ -90,6 +90,74 @@ const assertPublicLeafPath = ( } }; +const resolvePublicPeople = ( + id: string, + fields: ContentFieldMap, + people: unknown, + { exposed, localized }: { exposed: readonly string[]; localized: boolean }, +): string[] => { + if (people === undefined) return []; + if (!Array.isArray(people)) { + throw new ContentEngineError( + 'publicApi.people is a list of user field names, e.g. `people: ["author"]`.', + { contentTypeId: id }, + ); + } + + const names = people.map(String); + const duplicate = names.find( + (name, position) => names.indexOf(name) !== position, + ); + if (duplicate !== undefined) { + throw new ContentEngineError( + `publicApi.people lists "${duplicate}" twice.`, + { + contentTypeId: id, + }, + ); + } + + for (const name of names) { + const fieldValue = fields[name]; + if (!fieldValue) { + throw new ContentEngineError( + `publicApi.people references unknown field "${name}". Name a top-level \`field.user()\`.`, + { contentTypeId: id }, + ); + } + + if (fieldValue.kind !== "user") { + throw new ContentEngineError( + `publicApi.people includes "${name}", which is a "${fieldValue.kind}" field. Only a \`field.user()\` is published as a person - list anything else in publicApi.fields.`, + { contentTypeId: id }, + ); + } + + if (fieldValue.multiple) { + throw new ContentEngineError( + `publicApi.people includes the to-many user field "${name}". A person summary is published for one user per record - use a to-one \`field.user()\`.`, + { contentTypeId: id }, + ); + } + + if ((fieldValue as { localized?: boolean }).localized === true) { + throw new ContentEngineError( + `publicApi.people includes the localized field "${name}". A person is the same in every language - make the field shared.`, + { contentTypeId: id }, + ); + } + + if (exposed.includes(name) || (localized && name === "locale")) { + throw new ContentEngineError( + `publicApi.people includes "${name}", which the public response already uses for something else. Rename the field.`, + { contentTypeId: id }, + ); + } + } + + return names; +}; + export const resolvePublicApi = ( id: string, fields: ContentFieldMap, @@ -106,6 +174,7 @@ export const resolvePublicApi = ( filterableFields: [], orderableFields: [], path: "", + people: [], searchableFields: [], slugField: "", }; @@ -335,6 +404,10 @@ export const resolvePublicApi = ( filterableFields, orderableFields, path: publicApi.path, + people: resolvePublicPeople(id, fields, publicApi.people, { + exposed, + localized: Object.keys(localizedFields).length > 0, + }), searchableFields, slugField: slugFields[0], }; diff --git a/packages/vitnode/src/content/define-search.ts b/packages/vitnode/src/content/define-search.ts index 5f8f009b89..84df611b52 100644 --- a/packages/vitnode/src/content/define-search.ts +++ b/packages/vitnode/src/content/define-search.ts @@ -1,8 +1,10 @@ import type { + ContentFieldDescriptor, ContentFieldMap, ContentSearchConfig, ResolvedContentPublicApiConfig, ResolvedContentSearchConfig, + ResolvedContentSearchContext, } from "./types"; import { @@ -28,6 +30,7 @@ const searchTextKinds: ReadonlySet = new Set(CONTENT_SEARCH_TEXT_KINDS); const disabledSearch: ResolvedContentSearchConfig = { authorField: null, contentFields: [], + context: null, descriptionField: null, enabled: false, imageField: null, @@ -141,6 +144,34 @@ const assertSearchImageField = ( } }; +const assertSearchTitleRelation = ( + id: string, + descriptor: Extract, + exposed: ReadonlySet, + name: string, +): void => { + if (descriptor.multiple || descriptor.self) { + throw new ContentEngineError( + `search.titleField names the relation "${name}", which is ${descriptor.multiple ? "to-many" : "a self relation"}. A relation lends its record's search title only when it points at exactly one record of another content type.`, + { contentTypeId: id }, + ); + } + + if (descriptor.nullable) { + throw new ContentEngineError( + `search.titleField names the nullable relation "${name}". A search result needs a heading, so the relation must not be nullable.`, + { contentTypeId: id }, + ); + } + + if (!exposed.has(name)) { + throw new ContentEngineError( + `search.titleField names "${name}", which is not in publicApi.fields. Every indexed field must be publicly exposed.`, + { contentTypeId: id }, + ); + } +}; + const LEGACY_LOCALE_PREFIX = `/${CONTENT_SEARCH_LOCALE_PLACEHOLDER}/`; const resolveSearchPathTemplate = ( @@ -216,6 +247,84 @@ const resolveSearchPathTemplate = ( return source; }; +const resolveSearchContext = ({ + exposed, + fields, + id, + localized, + search, +}: { + exposed: ReadonlySet; + fields: ContentFieldMap; + id: string; + localized: boolean; + search: ContentSearchConfig; +}): null | ResolvedContentSearchContext => { + const titleRelation = + fields[search.titleField]?.kind === "relation" ? search.titleField : null; + const input = + search.context ?? (titleRelation ? { field: titleRelation } : undefined); + if (input === undefined) return null; + + const { field } = input; + if (titleRelation !== null && field !== titleRelation) { + throw new ContentEngineError( + `search.context names "${field}", but search.titleField borrows its title from the relation "${titleRelation}". A record that takes its heading from a related record belongs to that record, so the context has to be "${titleRelation}" too.`, + { contentTypeId: id }, + ); + } + + const descriptor = fields[field]; + if (descriptor?.kind === "container") { + if (input.pathTemplate === undefined) { + throw new ContentEngineError( + `search.context names the container field "${field}" without a pathTemplate. A container has no public URL of its own, so say where it lives, e.g. "/forum/{slug}".`, + { contentTypeId: id }, + ); + } + + return { + field, + kind: "container", + pathTemplate: resolveSearchPathTemplate(id, input.pathTemplate, false), + }; + } + + if ( + descriptor?.kind !== "relation" || + descriptor.multiple || + descriptor.self + ) { + throw new ContentEngineError( + `search.context names "${field}", which is not a \`field.container()\` or a to-one \`field.relation()\` to another content type.`, + { contentTypeId: id }, + ); + } + + if (localized) { + throw new ContentEngineError( + `search.context names the relation "${field}", but this content type is localized. A relation lends one context to every language, so it is only supported on content types that are not localized.`, + { contentTypeId: id }, + ); + } + + if (!exposed.has(field)) { + throw new ContentEngineError( + `search.context names "${field}", which is not in publicApi.fields. Every indexed field must be publicly exposed.`, + { contentTypeId: id }, + ); + } + + if (input.pathTemplate !== undefined) { + throw new ContentEngineError( + `search.context names the relation "${field}" with a pathTemplate. A related record links to its own search URL, so leave pathTemplate out.`, + { contentTypeId: id }, + ); + } + + return { field, kind: "relation", pathTemplate: "" }; +}; + export const resolveSearch = ( id: string, fields: ContentFieldMap, @@ -250,14 +359,25 @@ export const resolveSearch = ( const exposed = new Set(publicApi.fields); const { titleField } = search; - assertSearchField({ - exposed, - fields, - id, - kinds: searchTitleKinds, - label: "search.titleField", - name: titleField, - }); + const titleDescriptor = fields[titleField]; + if (titleDescriptor?.kind === "relation") { + if (localized) { + throw new ContentEngineError( + `search.titleField names the relation "${titleField}", but this content type is localized. A relation lends one title to every language, so it is only supported on content types that are not localized.`, + { contentTypeId: id }, + ); + } + assertSearchTitleRelation(id, titleDescriptor, exposed, titleField); + } else { + assertSearchField({ + exposed, + fields, + id, + kinds: searchTitleKinds, + label: "search.titleField", + name: titleField, + }); + } // A `null` title can never be a result heading, and a record whose title is // missing is skipped by the mapper - which shows up as a collection that never @@ -335,9 +455,18 @@ export const resolveSearch = ( assertSearchImageField(id, fields, exposed, imageField); } + const context = resolveSearchContext({ + exposed, + fields, + id, + localized, + search, + }); + return { authorField, contentFields, + context, descriptionField, enabled: true, imageField, diff --git a/packages/vitnode/src/content/define-thread.test.ts b/packages/vitnode/src/content/define-thread.test.ts new file mode 100644 index 0000000000..e43243fb11 --- /dev/null +++ b/packages/vitnode/src/content/define-thread.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; + +import { testForumContentType } from "@/tests/content-fixtures"; + +import { defineContentType } from "./define"; +import { + CONTENT_THREAD_ABSOLUTE_MAX_DEPTH, + CONTENT_THREAD_DEFAULT_MAX_DEPTH, + DISABLED_CONTENT_THREAD, +} from "./define-thread"; +import { ContentEngineError } from "./errors"; +import { field } from "./fields"; + +const topic = () => testForumContentType; + +const replyFields = { + slug: field.slug({ generated: true }), + body: field.text({ required: true }), + author: field.user(), + topic: field.relation({ target: topic, required: true }), + parent: field.relation({ self: true, nullable: true }), +}; + +interface Options { + fields?: Record; + localization?: unknown; + publicApi?: false | Record; + publicWrite?: false | Record; + thread?: unknown; +} + +const threadOf = (options: Options): unknown => { + if (!Object.hasOwn(options, "thread")) + return { parent: "parent", root: "topic" }; + const { thread } = options; + + return typeof thread === "object" && thread !== null + ? { parent: "parent", root: "topic", ...thread } + : thread; +}; + +const define = (options: Options = {}) => { + const { + fields = {}, + publicApi = {}, + publicWrite = {}, + thread: _thread, + ...rest + } = options; + + return defineContentType({ + id: "test.reply", + tableName: "test_replies", + publication: true, + fields: { ...replyFields, ...fields }, + publicApi: + publicApi === false + ? undefined + : { + path: "replies", + fields: ["id", "slug", "body", "topic", "parent"], + ...publicApi, + }, + publicWrite: + publicWrite === false + ? undefined + : { + ownership: { field: "author" }, + create: { fields: ["body", "topic", "parent"] }, + update: { fields: ["body"] }, + ...publicWrite, + }, + thread: threadOf(options), + ...rest, + } as never); +}; + +describe("thread", () => { + it.each([false, undefined])("is off when it is %s", thread => { + expect(define({ thread }).thread).toEqual(DISABLED_CONTENT_THREAD); + }); + + it("resolves the fields and the default depth", () => { + expect(define().thread).toEqual({ + enabled: true, + maxDepth: CONTENT_THREAD_DEFAULT_MAX_DEPTH, + parentField: "parent", + rootField: "topic", + }); + }); + + it.each([1, CONTENT_THREAD_ABSOLUTE_MAX_DEPTH])( + "keeps a maxDepth of %s", + maxDepth => { + expect(define({ thread: { maxDepth } }).thread.maxDepth).toBe(maxDepth); + }, + ); + + it.each<[string, Options, RegExp]>([ + ["a thread that is not an object", { thread: true }, /thread is `\{/], + [ + "an unknown option", + { thread: { depth: 3 } }, + /thread\.depth is not an option/, + ], + [ + "a parent that is not a relation", + { thread: { parent: "body" } }, + /thread\.parent "body" must be a to-one/, + ], + [ + "a parent that points at another type", + { + fields: { previous: field.relation({ target: topic, nullable: true }) }, + thread: { parent: "previous" }, + }, + /thread\.parent "previous" must be a to-one/, + ], + [ + "a to-many parent", + { + fields: { answers: field.relation({ multiple: true, self: true }) }, + thread: { parent: "answers" }, + }, + /thread\.parent "answers" must be a to-one/, + ], + [ + "a parent that is not nullable", + { + fields: { + parent: { ...replyFields.parent, nullable: false, required: true }, + }, + }, + /Self-relation "parent" is to-one but not nullable/, + ], + [ + "a self root", + { + fields: { origin: field.relation({ self: true, nullable: true }) }, + thread: { root: "origin" }, + }, + /thread\.root "origin" must be a required, to-one/, + ], + [ + "an optional root", + { + fields: { topic: field.relation({ target: topic, nullable: true }) }, + }, + /thread\.root "topic" must be a required, to-one/, + ], + [ + "a to-many root", + { + fields: { topics: field.relation({ multiple: true, target: topic }) }, + thread: { root: "topics" }, + }, + /thread\.root "topics" must be a required, to-one/, + ], + ...[0, -1, 1.5, CONTENT_THREAD_ABSOLUTE_MAX_DEPTH + 1].map( + maxDepth => + [ + `a maxDepth of ${maxDepth}`, + { thread: { maxDepth } }, + /thread\.maxDepth must be a whole number from 1 to 32/, + ] as [string, Options, RegExp], + ), + [ + "a localized content type", + { + fields: { body: field.text({ localized: true, required: true }) }, + localization: { defaultLocale: "en" }, + }, + /not available on a localized content type/, + ], + [ + "no publicApi", + { publicApi: false, publicWrite: false }, + /thread needs `publicApi`/, + ], + [ + "a parent left out of publicApi.fields", + { publicApi: { fields: ["id", "slug", "body", "topic"] } }, + /thread uses "parent", so list it in `publicApi\.fields`/, + ], + [ + "a root left out of publicApi.fields", + { publicApi: { fields: ["id", "slug", "body", "parent"] } }, + /thread uses "topic", so list it in `publicApi\.fields`/, + ], + [ + "a parent that public updates may move", + { publicWrite: { update: { fields: ["body", "parent"] } } }, + /publicWrite\.update\.fields includes "parent"/, + ], + [ + "a root that public updates may move", + { publicWrite: { update: { fields: ["body", "topic"] } } }, + /publicWrite\.update\.fields includes "topic"/, + ], + ])("rejects %s", (_label, options, message) => { + const run = () => define(options); + + expect(run).toThrow(ContentEngineError); + expect(run).toThrow(message); + }); +}); diff --git a/packages/vitnode/src/content/define-thread.ts b/packages/vitnode/src/content/define-thread.ts new file mode 100644 index 0000000000..f3f62975f4 --- /dev/null +++ b/packages/vitnode/src/content/define-thread.ts @@ -0,0 +1,141 @@ +import type { + ContentFieldDescriptor, + ContentFieldMap, + ContentThreadInput, + ResolvedContentPublicApiConfig, + ResolvedContentPublicWriteConfig, + ResolvedContentThreadConfig, +} from "./types"; + +import { ContentEngineError } from "./errors"; + +export const CONTENT_THREAD_DEFAULT_MAX_DEPTH = 8; + +export const CONTENT_THREAD_ABSOLUTE_MAX_DEPTH = 32; + +export const DISABLED_CONTENT_THREAD: ResolvedContentThreadConfig = { + enabled: false, + maxDepth: 0, + parentField: "", + rootField: "", +}; + +const isToOneRelation = ( + field: ContentFieldDescriptor | undefined, +): field is Extract => + field?.kind === "relation" && !field.multiple; + +/** + * Resolves `thread`: records of one content type that answer each other under + * a shared root - forum replies under a topic, comments under a post. + * + * `parent` is a to-one self relation (`null` for a top-level reply), `root` a + * to-one relation to the record the thread hangs off. Both are set on create + * and never move, which is what keeps cycles impossible. + */ +export const resolveContentThread = ( + id: string, + fields: ContentFieldMap, + thread: ContentThreadInput | false | undefined, + { + localized, + publicApi, + publicWrite, + }: { + localized: boolean; + publicApi: ResolvedContentPublicApiConfig; + publicWrite: ResolvedContentPublicWriteConfig; + }, +): ResolvedContentThreadConfig => { + if (thread === undefined || thread === false) return DISABLED_CONTENT_THREAD; + + if (typeof thread !== "object" || thread === null) { + throw new ContentEngineError( + "thread is `{ parent, root, maxDepth? }` or `false`.", + { contentTypeId: id }, + ); + } + + const unknown = Object.keys(thread).find( + key => key !== "parent" && key !== "root" && key !== "maxDepth", + ); + if (unknown !== undefined) { + throw new ContentEngineError( + `thread.${unknown} is not an option. thread accepts: parent, root, maxDepth.`, + { contentTypeId: id }, + ); + } + + const parent = fields[thread.parent]; + if (!isToOneRelation(parent) || !parent.self) { + throw new ContentEngineError( + `thread.parent "${thread.parent}" must be a to-one \`field.relation({ self: true, nullable: true })\` - the reply being answered.`, + { contentTypeId: id }, + ); + } + if (!parent.nullable) { + throw new ContentEngineError( + `thread.parent "${thread.parent}" must be nullable: a top-level reply answers nobody.`, + { contentTypeId: id }, + ); + } + + const root = fields[thread.root]; + if (!isToOneRelation(root) || root.self || !root.required) { + throw new ContentEngineError( + `thread.root "${thread.root}" must be a required, to-one \`field.relation({ target })\` to another content type - the record the thread belongs to.`, + { contentTypeId: id }, + ); + } + + const maxDepth = thread.maxDepth ?? CONTENT_THREAD_DEFAULT_MAX_DEPTH; + if ( + !Number.isInteger(maxDepth) || + maxDepth < 1 || + maxDepth > CONTENT_THREAD_ABSOLUTE_MAX_DEPTH + ) { + throw new ContentEngineError( + `thread.maxDepth must be a whole number from 1 to ${CONTENT_THREAD_ABSOLUTE_MAX_DEPTH}.`, + { contentTypeId: id }, + ); + } + + if (localized) { + throw new ContentEngineError( + "thread is not available on a localized content type yet: replies are written in one language.", + { contentTypeId: id }, + ); + } + + if (!publicApi.enabled) { + throw new ContentEngineError( + "thread needs `publicApi`: the thread route lives beside the public read routes.", + { contentTypeId: id }, + ); + } + for (const name of [thread.parent, thread.root]) { + if (!(publicApi.fields as readonly string[]).includes(name)) { + throw new ContentEngineError( + `thread uses "${name}", so list it in \`publicApi.fields\` - a reader rebuilds the tree from it.`, + { contentTypeId: id }, + ); + } + } + + const moved = [thread.parent, thread.root].find(name => + publicWrite.update.fields.includes(name), + ); + if (moved !== undefined) { + throw new ContentEngineError( + `publicWrite.update.fields includes "${moved}". A reply is placed in its thread when it is written and stays there - leave it out of \`update\`.`, + { contentTypeId: id }, + ); + } + + return { + enabled: true, + maxDepth, + parentField: thread.parent, + rootField: thread.root, + }; +}; diff --git a/packages/vitnode/src/content/define.test.ts b/packages/vitnode/src/content/define.test.ts index 9418ab978b..8a8f1a9519 100644 --- a/packages/vitnode/src/content/define.test.ts +++ b/packages/vitnode/src/content/define.test.ts @@ -110,6 +110,21 @@ describe("defineContentType", () => { describe("container fields", () => { const boards = defineContentContainer({ id: "test.boards" }); + it("accepts a required to-one container relation", () => { + expect(() => + define({ + fields: { + title: field.text({ required: true }), + topic: field.relation({ + container: true, + required: true, + target: () => testCategoryContentType, + }), + }, + }), + ).not.toThrow(); + }); + it.each([true, false])("accepts a container with required %s", required => { expect(() => define({ @@ -165,6 +180,67 @@ describe("defineContentType", () => { }, /declares 2 container fields \(board, section\)/, ], + [ + "a to-many container relation", + { + topics: field.relation({ + container: true, + multiple: true, + target: () => testCategoryContentType, + }), + }, + /Relation "topics" is `container: true` and `multiple: true`/, + ], + [ + "a self container relation", + { + parent: field.relation({ + container: true, + nullable: true, + self: true, + }), + }, + /Relation "parent" is `container: true` and `self: true`/, + ], + [ + "an optional container relation", + { + topic: field.relation({ + container: true, + nullable: true, + target: () => testCategoryContentType, + }), + }, + /Relation "topic" is `container: true` but not required/, + ], + [ + "a container field and a container relation", + { + board: field.container({ definition: boards }), + topic: field.relation({ + container: true, + required: true, + target: () => testCategoryContentType, + }), + }, + /takes its container from 2 fields \(board, topic\)/, + ], + [ + "two container relations", + { + topic: field.relation({ + container: true, + required: true, + target: () => testCategoryContentType, + }), + thread: field.relation({ + container: true, + required: true, + target: () => testCategoryContentType, + }), + }, + /takes its container from 2 fields \(topic, thread\)/, + ], ])("rejects %s", (_label, fields, message) => { expect(() => define({ @@ -314,6 +390,36 @@ describe("defineContentType", () => { ).toMatchObject({ required: false }); }); + it("is optional in the create payload when generated", () => { + const definition = defineSlug(field.slug({ generated: true })); + + expect( + definition.schemas.create.safeParse({ title: "Hello" }).success, + ).toBe(true); + }); + + it.each([ + [ + "a source", + field.slug({ generated: true, source: "title" } as never), + {}, + /is both `generated: true` and sourced from "title"/, + ], + [ + "localization", + field.slug({ generated: true, localized: true }), + { localization: { defaultLocale: "en" } }, + /is `generated: true` and `localized: true`/, + ], + ])("rejects a generated slug with %s", (_label, slug, extra, message) => { + expect(() => + define({ + fields: { title: field.text({ required: true }), slug }, + ...extra, + }), + ).toThrow(message); + }); + it("rejects a source that does not exist", () => { expect(() => defineSlug(field.slug({ source: "nope" }))).toThrow( /sourced from "nope", which is not a field/, diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 26f3d38ca5..5ad2965c1e 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -21,6 +21,8 @@ import type { ContentPublicApiConfig, ContentPublicExposableField, ContentPublicOwnershipField, + ContentPublicPeopleResolved, + ContentPublicPersonField, ContentPublicWritableField, ContentPublicWriteInput, ContentPublicWriteResolved, @@ -32,6 +34,7 @@ import type { ContentSearchEnabled, ContentSearchTextField, ContentSearchTitleField, + ContentThreadInput, ContentTypeDefinition, ResolvedContentDeliveryConfig, ResolvedContentDuplicationConfig, @@ -80,6 +83,7 @@ import { systemFields, visibilityFields, } from "./define-shared"; +import { resolveContentThread } from "./define-thread"; import { resolveContentDelivery } from "./delivery"; import { ContentEngineError } from "./errors"; import { resolveContentIndexes } from "./indexes"; @@ -222,6 +226,7 @@ export const defineContentType = < ContentPublicOwnershipField > | undefined = undefined, + TPublicPeople extends ContentPublicPersonField = never, >({ admin = {}, delivery, @@ -239,6 +244,7 @@ export const defineContentType = < reports, search, tableName, + thread, visibility, }: { admin?: ContentAdminConfig< @@ -269,7 +275,9 @@ export const defineContentType = < * Opts into a generated read-only public API. Needs `publication` and exactly * one exposed slug field. Omit it and nothing public is generated. */ - publicApi?: ContentPublicApiConfig | false; + publicApi?: + | ContentPublicApiConfig + | false; /** Opts into the draft/published lifecycle. Omit to stay on Stage 1 behaviour. */ publication?: TPublication; publicWrite?: TPublicWrite; @@ -277,6 +285,11 @@ export const defineContentType = < search?: TSearch; tableName: string; + /** + * Records that answer each other under one root - forum replies under a + * topic. Adds the public `GET /thread` route and server-side parent checks. + */ + thread?: ContentThreadInput | false; visibility?: TVisibility; }): ContentTypeDefinition< TId, @@ -294,7 +307,8 @@ export const defineContentType = < TVisibility, ContentDuplicationEnabled, TIdStrategy, - ContentPublicWriteResolved + ContentPublicWriteResolved, + ContentPublicPeopleResolved > => { if (!CONTENT_ID_PATTERN.test(id)) { throw new ContentEngineError( @@ -542,6 +556,12 @@ export const defineContentType = < publicApi: resolvedPublicApi, }); + const resolvedThread = resolveContentThread(id, fieldMap, thread, { + localized: resolvedLocalization.enabled, + publicApi: resolvedPublicApi, + publicWrite: resolvedPublicWrite, + }); + const resolvedDuplication = resolveDuplication( id, duplication, @@ -565,7 +585,8 @@ export const defineContentType = < TVisibility, ContentDuplicationEnabled, TIdStrategy, - ContentPublicWriteResolved + ContentPublicWriteResolved, + ContentPublicPeopleResolved > = { admin: resolvedAdmin, advanced: resolvedAdvanced, @@ -594,10 +615,12 @@ export const defineContentType = < enabled: publicationEnabled as TPublication, }, reports: resolvedReports, + thread: resolvedThread, publicApi: resolvedPublicApi as ResolvedContentPublicApiConfig< TPublicField, TPublicEnabled, - TPublicPath + TPublicPath, + ContentPublicPeopleResolved >, publicWrite: resolvedPublicWrite as ContentPublicWriteResolved, @@ -618,7 +641,8 @@ export const defineContentType = < TVisibility, ContentDuplicationEnabled, TIdStrategy, - ContentPublicWriteResolved + ContentPublicWriteResolved, + ContentPublicPeopleResolved > >({ admin: resolvedAdmin, diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts index efd37fa79e..9ca352084b 100644 --- a/packages/vitnode/src/content/errors.ts +++ b/packages/vitnode/src/content/errors.ts @@ -434,17 +434,22 @@ export type ContentPublicWriteDenial = export class ContentPublicWriteDenied extends ContentEngineError { constructor({ contentTypeId, + issue = null, reason, }: { contentTypeId: string; + /** Why, when the reason alone would leave the member guessing. */ + issue?: "edit_window_closed" | "moderators_only" | null; reason: ContentPublicWriteDenial; }) { super(`Public write denied: ${reason}.`, { contentTypeId }); this.name = "ContentPublicWriteDenied"; + this.issue = issue; this.reason = reason; } + readonly issue: "edit_window_closed" | "moderators_only" | null; readonly reason: ContentPublicWriteDenial; } diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts index 08111c440c..ddba623049 100644 --- a/packages/vitnode/src/content/fields.ts +++ b/packages/vitnode/src/content/fields.ts @@ -166,21 +166,32 @@ const enumField = < const slug = < TSource extends string | undefined = undefined, TLocalized extends boolean = false, + TGenerated extends boolean = false, >( args: LocalizableArgs & { description?: string; + /** A random token minted on create. Mutually exclusive with `source`. */ + generated?: TGenerated; /** `varchar` length and the truncation point. Defaults to 160. */ maxLength?: number; source?: TSource; } = {}, -): ContentSlugField => ({ - ...args, - kind: "slug", - localized: localizedOf(args), - nullable: false, - required: (args.source === undefined) as ContentSlugRequired, - source: args.source as TSource, -}); +): ContentSlugField => { + const generated = (args.generated ?? false) as TGenerated; + + return { + ...args, + generated, + kind: "slug", + localized: localizedOf(args), + nullable: false, + required: (args.source === undefined && !generated) as ContentSlugRequired< + TSource, + TGenerated + >, + source: args.source as TSource, + }; +}; const dateTime = < TRequired extends boolean = false, @@ -297,8 +308,14 @@ const relation = < TOrdered extends boolean = false, TSelf extends boolean = false, TTarget extends ContentRelationTarget = ContentRelationTarget<"serial">, + TContainer extends boolean = false, >( args: SharedArgs & { + /** + * The record lives in the related record's container - a reply sits in its + * topic's forum. To-one, required and never `self`. + */ + container?: TContainer; min?: number; multiple?: TMultiple; onDelete?: ContentOnDelete; @@ -313,10 +330,12 @@ const relation = < TMultiple, TOrdered, TSelf, - TSelf extends true ? "serial" : TTarget["idStrategy"] + TSelf extends true ? "serial" : TTarget["idStrategy"], + TContainer > => ({ ...args, ...shared(args), + container: (args.container ?? false) as TContainer, kind: "relation", // The assertions keep the literal the caller inferred, exactly as `shared` // and `localizedOf` do: `?? false` alone widens back to `boolean`, and every diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index abd72d591c..1d37375a26 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -60,6 +60,19 @@ export { uploadContentFile, } from "./admin/upload"; export type { ContentUploadRejection } from "./admin/upload"; +export { + CONTENT_SUBMISSION_DECISION_CODES, + CONTENT_SUBMISSION_KINDS, + CONTENT_SUBMISSION_LIMITS, + CONTENT_SUBMISSION_STATUSES, +} from "./approvals/const"; +export type { + ContentSubmissionDecisionCode, + ContentSubmissionKind, + ContentSubmissionStatus, +} from "./approvals/const"; +export { zodContentApprovalState } from "./approvals/schema"; +export type { ContentApprovalState } from "./approvals/schema"; export { contentContainerTag, contentDeliveryRedirectTag, @@ -196,18 +209,49 @@ export { export { ContentContainerAssignmentError, contentContainerFieldOf, + contentContainerSourceOf, +} from "./containers/association"; +export type { + ContentContainerFieldEntry, + ContentContainerSource, } from "./containers/association"; -export type { ContentContainerFieldEntry } from "./containers/association"; export { CONTENT_CONTAINER_ABSOLUTE_MAX_DEPTH, CONTENT_CONTAINER_ERROR_CODES, CONTENT_CONTAINER_FIELD_KINDS, + CONTENT_CONTAINER_POLICY_AUTHOR_EDIT, + CONTENT_CONTAINER_POLICY_CREATE, + CONTENT_CONTAINER_POLICY_KEYS, + CONTENT_CONTAINER_POLICY_MAX_MINUTES, + CONTENT_CONTAINER_POLICY_MODES, + CONTENT_CONTAINER_TREE_SUFFIX, } from "./containers/const"; export { defineContentContainer, isContentContainerDefinition, } from "./containers/define"; export { ContentContainerError } from "./containers/errors"; +export { + contentAuthorMayEdit, + contentContainerPolicyFallback, + resolveContentContainerPolicy, + zodContentContainerPolicy, + zodContentContainerPolicyOverrides, + zodEffectiveContentContainerPolicy, +} from "./containers/policies"; +export type { + ContentContainerPoliciesInput, + ContentContainerPolicy, + ContentContainerPolicyAuthorEdit, + ContentContainerPolicyCreate, + ContentContainerPolicyInput, + ContentContainerPolicyKey, + ContentContainerPolicyMode, + ContentContainerPolicyOverrides, + ContentContainerPolicySource, + EffectiveContentContainerPolicy, + ResolvedContentContainerPolicies, +} from "./containers/policies"; export { contentContainerPermissionEntries, validateContentContainers, @@ -384,6 +428,8 @@ export { resolveContentLocalization, } from "./localization"; export type { ContentFieldPartition } from "./localization"; +export { isContentPublicPerson, zodContentPublicPerson } from "./public-person"; +export type { ContentPublicPerson } from "./public-person"; export { contentPublicHref, contentPublicUrl, @@ -542,6 +588,8 @@ export type { ContentPublicListRow, ContentPublicOrderableFieldName, ContentPublicOwnershipField, + ContentPublicPeopleName, + ContentPublicPersonField, ContentPublicRelation, ContentPublicSelect, ContentPublicUpdateBody, diff --git a/packages/vitnode/src/content/public-person.ts b/packages/vitnode/src/content/public-person.ts new file mode 100644 index 0000000000..77203f0544 --- /dev/null +++ b/packages/vitnode/src/content/public-person.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +/** The public summary of a person named by `publicApi.people`. */ +export interface ContentPublicPerson { + avatarColor: string; + avatarUrl: null | string; + id: number; + name: string; + nameCode: string; + role: { color: null | string; prefix: null | string }; +} + +export const zodContentPublicPerson = z.object({ + avatarColor: z.string(), + avatarUrl: z.string().nullable(), + id: z.number(), + name: z.string(), + nameCode: z.string(), + role: z.object({ + color: z.string().nullable(), + prefix: z.string().nullable(), + }), +}); + +export const isContentPublicPerson = ( + value: unknown, +): value is ContentPublicPerson => + typeof value === "object" && + value !== null && + typeof (value as { id?: unknown }).id === "number" && + typeof (value as { nameCode?: unknown }).nameCode === "string"; diff --git a/packages/vitnode/src/content/public-write.test-d.ts b/packages/vitnode/src/content/public-write.test-d.ts index e4f1abc248..2c2e58718c 100644 --- a/packages/vitnode/src/content/public-write.test-d.ts +++ b/packages/vitnode/src/content/public-write.test-d.ts @@ -104,7 +104,7 @@ describe("publicWrite types", () => { type Record = ContentPublicWriteRecord; expectTypeOf().toEqualTypeOf< - "id" | "publishedAt" | "slug" | "status" | "values" + "approval" | "id" | "publishedAt" | "slug" | "status" | "values" >(); expectTypeOf< Record["status"] diff --git a/packages/vitnode/src/content/public.test-d.ts b/packages/vitnode/src/content/public.test-d.ts index c866cad54b..2d2ab83289 100644 --- a/packages/vitnode/src/content/public.test-d.ts +++ b/packages/vitnode/src/content/public.test-d.ts @@ -156,9 +156,9 @@ describe("publicApi types", () => { describe("the public service", () => { type Service = ContentPublicService; - it("reads three ways and writes none", () => { + it("reads four ways and writes none", () => { expectTypeOf().toEqualTypeOf< - "findById" | "findBySlug" | "findMany" + "findById" | "findBySlug" | "findMany" | "findManyByIds" >(); }); diff --git a/packages/vitnode/src/content/public.test.ts b/packages/vitnode/src/content/public.test.ts index e5166394e5..5b8a7b0531 100644 --- a/packages/vitnode/src/content/public.test.ts +++ b/packages/vitnode/src/content/public.test.ts @@ -259,6 +259,41 @@ describe("publicApi", () => { }); }); + describe("people", () => { + it("publishes no one until asked", () => { + expect(define().publicApi.people).toEqual([]); + }); + + it("keeps the user fields it was given", () => { + expect( + define({ publicApi: { people: ["author"] } }).publicApi.people, + ).toEqual(["author"]); + }); + + it.each([ + ["a list", { people: "author" }, /is a list of user field names/], + ["an unknown field", { people: ["ghost"] }, /unknown field "ghost"/], + [ + "a field that is not a user", + { people: ["title"] }, + /"title", which is a "text" field/, + ], + [ + "a to-many user", + { people: ["editors"] }, + /to-many user field "editors"/, + ], + ["a duplicate", { people: ["author", "author"] }, /lists "author" twice/], + ])("rejects %s", (_label, publicApi, message) => { + expect(() => + define({ + fields: { ...baseFields, editors: field.user({ multiple: true }) }, + publicApi, + }), + ).toThrow(message); + }); + }); + describe("generated schemas", () => { const { schemas } = testPostContentType; @@ -421,6 +456,36 @@ describe("publicApi on a localized content type", () => { ).toThrow(/reserves/); }); + it.each([ + [ + "a localized user", + { author: { ...field.user(), localized: true } }, + ["author"], + /people includes the localized field "author"/, + ], + [ + "a user called `locale`", + { locale: field.user() }, + ["locale"], + /"locale", which the public response already uses/, + ], + ])("refuses %s in `people`", (_label, fields, people, message) => { + expect(() => + defineContentType({ + id: "test.public-people", + tableName: "test_public_people", + localization: { defaultLocale: "en" }, + publication: true, + fields: { + title: field.text({ localized: true, required: true }), + slug: field.slug({ localized: true, source: "title" }), + ...fields, + }, + publicApi: { fields: ["title", "slug"], path: "people", people }, + } as never), + ).toThrow(message); + }); + it("leaves a field called `locale` alone when nothing is localized", () => { // The reservation is a consequence of localization, not a global rename. expect( diff --git a/packages/vitnode/src/content/registry.test.ts b/packages/vitnode/src/content/registry.test.ts index 194527ce6e..a0552ac756 100644 --- a/packages/vitnode/src/content/registry.test.ts +++ b/packages/vitnode/src/content/registry.test.ts @@ -5,11 +5,14 @@ import { testCategoryContentType, testForumContentType, testPostContentType, + testThreadContentType, testTopicContentType, } from "@/tests/content-fixtures"; import type { RegisteredContentType } from "./registry"; +import type { AnyContentTypeDefinition } from "./types"; +import { defineContentContainer } from "./containers/define"; import { defineContentType } from "./define"; import { ContentEngineError } from "./errors"; import { field } from "./fields"; @@ -19,6 +22,7 @@ import { contentCreateHref, contentEditHref, contentEditHrefTemplate, + contentModeratorPermissionEntries, contentTypeToPath, findContentTypeById, orderableColumns, @@ -900,3 +904,79 @@ describe("delivery paths", () => { ).not.toThrow(); }); }); + +describe("contentModeratorPermissionEntries", () => { + const forums = defineContentContainer({ id: "test.forums", policies: {} }); + + const topicIn = ( + publicWrite: Record, + ): AnyContentTypeDefinition => + defineContentType({ + id: "test.forum-topic", + tableName: "test_forum_topics", + publication: true, + fields: { + title: field.text({ required: true }), + slug: field.slug({ source: "title" }), + author: field.user(), + forum: field.container({ definition: forums, required: true }), + }, + publicApi: { path: "forum-topics", fields: ["id", "title", "slug"] }, + publicWrite: { + ownership: { field: "author" }, + create: { fields: ["title", "forum"] }, + ...publicWrite, + }, + } as never); + + const topic = topicIn({ update: { fields: ["title"] } }); + + const reply: AnyContentTypeDefinition = defineContentType({ + id: "test.forum-reply", + tableName: "test_forum_replies", + publication: true, + fields: { + slug: field.slug({ generated: true }), + body: field.text({ required: true }), + author: field.user(), + topic: field.relation({ + container: true, + required: true, + target: () => topic, + }), + }, + publicApi: { path: "forum-replies", fields: ["id", "slug", "body"] }, + publicWrite: { + ownership: { field: "author" }, + create: { fields: ["body", "topic"] }, + update: { fields: ["body"] }, + }, + } as never); + + const scoped = (permission: string) => ({ permission, scope: forums.id }); + + it.each<[string, AnyContentTypeDefinition, unknown[]]>([ + [ + "an unscoped can_edit in a container without policies", + testThreadContentType, + ["can_edit"], + ], + [ + "scoped can_edit and can_approve in a container with policies", + topic, + [scoped("can_edit"), scoped("can_approve")], + ], + [ + "the same, scoped by an inherited container", + reply, + [scoped("can_edit"), scoped("can_approve")], + ], + [ + "only can_approve when public writes cannot update", + topicIn({}), + [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 1ed915745c..3c50e0b7a5 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -16,6 +16,7 @@ import { isFilterableFieldKind, RESERVED_FILTER_KEYS, } from "./const"; +import { contentContainerSourceOf } from "./containers/association"; import { ContentEngineError } from "./errors"; import { contentIdKey } from "./ids"; import { partitionContentFields } from "./localization"; @@ -420,10 +421,29 @@ export const contentPermissionEntries = ( // may edit an article may edit every language of it. ]; +/** + * 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. + */ export const contentModeratorPermissionEntries = ( definition: AnyContentTypeDefinition, -): PermissionStaffEntryInput[] => - definition.publicWrite.update.enabled ? [CONTENT_PERMISSIONS.edit] : []; +): PermissionStaffEntryInput[] => { + const container = contentContainerSourceOf(definition)?.container; + const scope = container?.policies.enabled ? container.id : undefined; + const scoped = (permission: string) => + scope ? { permission, scope } : permission; + + return [ + ...(definition.publicWrite.update.enabled + ? [scoped(CONTENT_PERMISSIONS.edit)] + : []), + ...(scope && definition.publicWrite.enabled + ? [scoped(CONTENT_PERMISSIONS.approve)] + : []), + ]; +}; /** * Merges the derived content permissions into a plugin's `permissionStaff`. diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index cb2857bac3..b8a6e5894b 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -30,6 +30,7 @@ import { contentRepeatableMax, contentRepeatableMin, } from "./advanced"; +import { zodContentApprovalState } from "./approvals/schema"; import { CONTENT_EDITORIAL_FIELDS, CONTENT_LOCALE_MAX_LENGTH, @@ -54,6 +55,7 @@ import { isContentReferenceCollection, splitContentFieldPath, } from "./paths"; +import { zodContentPublicPerson } from "./public-person"; import { createRichTextDocumentSchema, RICH_TEXT_ABSOLUTE_MAX_BYTES, @@ -68,6 +70,7 @@ const DISABLED_PUBLIC_API: ResolvedContentPublicApiConfig = { filterableFields: [], orderableFields: [], path: "", + people: [], searchableFields: [], slugField: "", }; @@ -680,6 +683,9 @@ const publicSelectShape = ( // that was asked for. `defineContentType` reserves the name on a localized // content type, so this cannot shadow a declared field. ...(localization.enabled ? { locale: z.string() } : {}), + ...Object.fromEntries( + publicApi.people.map(name => [name, zodContentPublicPerson.nullable()]), + ), ...Object.fromEntries( publicApi.fields .filter(name => splitContentFieldPath(name) === null) @@ -766,6 +772,7 @@ const buildPublicWriteSchemas = ({ ]; const record = z.object({ + approval: zodContentApprovalState.nullable(), id: recordIdOutputSchema(idStrategy), publishedAt: z.date().nullable(), slug: z.string().nullable(), diff --git a/packages/vitnode/src/content/search.test.ts b/packages/vitnode/src/content/search.test.ts index d4a7c64e45..fd37ef933a 100644 --- a/packages/vitnode/src/content/search.test.ts +++ b/packages/vitnode/src/content/search.test.ts @@ -70,6 +70,7 @@ describe("search configuration", () => { expect(definition.search).toEqual({ authorField: null, contentFields: ["excerpt", "body"], + context: null, descriptionField: "excerpt", enabled: true, imageField: null, @@ -82,6 +83,7 @@ describe("search configuration", () => { expect(define().search).toEqual({ authorField: null, contentFields: [], + context: null, descriptionField: null, enabled: false, imageField: null, diff --git a/packages/vitnode/src/content/search.ts b/packages/vitnode/src/content/search.ts index da961fc26a..f439668889 100644 --- a/packages/vitnode/src/content/search.ts +++ b/packages/vitnode/src/content/search.ts @@ -7,6 +7,29 @@ import { contentIdKey } from "./ids"; import { isContentReferenceCollection, splitContentFieldPath } from "./paths"; import { contentPublicHref, contentPublicUrl } from "./public-url"; +export const contentSearchTemplateUrl = ({ + pathTemplate, + routing, + slug, +}: { + pathTemplate: string; + routing: ContentLocaleRouting; + slug: string; +}): null | string => { + const trimmed = slug.trim(); + if (trimmed === "" || pathTemplate === "") return null; + + const url = contentPublicUrl({ + internalPath: pathTemplate + .split(CONTENT_SEARCH_SLUG_PLACEHOLDER) + .join(encodeURIComponent(trimmed)), + locale: routing.defaultLocale, + routing, + }); + + return url === null ? null : contentPublicHref(url); +}; + export const contentSearchUrl = ({ definition, locale, @@ -40,6 +63,53 @@ export const contentSearchUrl = ({ return url === null ? null : contentPublicHref(url); }; +export type ContentSearchContextSource = + | { + containerType: string; + field: string; + kind: "container"; + pathTemplate: string; + } + | { field: string; kind: "relation"; target: AnyContentTypeDefinition }; + +export const contentSearchContextSource = ( + definition: AnyContentTypeDefinition, +): ContentSearchContextSource | null => { + const context = definition.search.enabled ? definition.search.context : null; + if (!context) return null; + + const descriptor = definition.fields[context.field]; + if (context.kind === "relation" && descriptor?.kind === "relation") { + return { + field: context.field, + kind: "relation", + target: descriptor.target(), + }; + } + if (context.kind === "container" && descriptor?.kind === "container") { + return { + containerType: descriptor.definition.id, + field: context.field, + kind: "container", + pathTemplate: context.pathTemplate, + }; + } + + return null; +}; + +export const contentSearchTitleRelation = ( + definition: AnyContentTypeDefinition, +): null | { field: string; target: AnyContentTypeDefinition } => { + const { search } = definition; + if (!search.enabled) return null; + + const descriptor = definition.fields[search.titleField]; + if (descriptor?.kind !== "relation") return null; + + return { field: search.titleField, target: descriptor.target() }; +}; + export const contentSearchDocumentId = ( definition: AnyContentTypeDefinition, id: ContentId, @@ -71,6 +141,7 @@ export const contentSearchIndexedFieldNames = ( publicApi.slugField, search.authorField, search.imageField, + search.context?.field, ].filter((name): name is string => Boolean(name)), ), ]; diff --git a/packages/vitnode/src/content/server/duplicate.ts b/packages/vitnode/src/content/server/duplicate.ts index d483840130..e29c8222b2 100644 --- a/packages/vitnode/src/content/server/duplicate.ts +++ b/packages/vitnode/src/content/server/duplicate.ts @@ -49,6 +49,7 @@ import { } from "../paths"; import { slugify } from "../slug"; import { contentSlugHistoryFor } from "./delivery-writes"; +import { generateContentSlug } from "./slugs"; export interface ContentDuplicateOptions { overrides?: ContentUpdateInput; @@ -391,10 +392,18 @@ export const runContentDuplicate = async ( fieldValue.kind === "slug" && overridden[name] === undefined, ) .map(([name, fieldValue]) => { - const { maxLength = CONTENT_SLUG_DEFAULT_LENGTH, source: from } = - fieldValue as { maxLength?: number; source?: string }; - const derived = - from !== undefined && typeof values[from] === "string" + const { + generated, + maxLength = CONTENT_SLUG_DEFAULT_LENGTH, + source: from, + } = fieldValue as { + generated?: boolean; + maxLength?: number; + source?: string; + }; + const derived = generated + ? generateContentSlug(maxLength) + : from !== undefined && typeof values[from] === "string" ? slugify(values[from], maxLength) : ""; const current = typeof source[name] === "string" ? source[name] : ""; diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index 3d6b32a7b3..c899b9ddf6 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -47,6 +47,7 @@ import type { ContentVisibilityMembers, } from "./visibility"; +import { cancelContentSubmissions } from "../approvals/server/submissions"; import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS, @@ -85,6 +86,7 @@ import { diffChangedPaths, toInsertColumns, } from "./query"; +import { assertNoContentSelfReference } from "./references"; import { contentRevisionSnapshot, projectRevisionSnapshot, @@ -92,6 +94,7 @@ import { import { createContentRevisionsModel } from "./revisions-model"; import { createContentSchedulesModel } from "./schedules-model"; import { createSlugNormalizer } from "./slugs"; +import { assertContentThreadPlacement } from "./thread"; import { isContentBaseRowPublic, isInVisibilityState, @@ -958,6 +961,14 @@ export const createContentEditorialService = < // about another. await assertContentFileReferences(c, definition, parsed, tx); await resolveContentContainerAssignment(tx, definition, parsed); + await assertContentThreadPlacement( + tx, + { columns, definition, table }, + { + id: null, + values: parsed, + }, + ); const [row] = await tx .insert(table) @@ -1067,6 +1078,7 @@ export const createContentEditorialService = < before: row, itemId: id, }); + await cancelContentSubmissions(tx, definition, id); return { changed: true, @@ -1311,6 +1323,15 @@ export const createContentEditorialService = < ...(await store?.load(id, tx, galleryFieldNames)), }), }); + assertNoContentSelfReference(definition, id, patch); + await assertContentThreadPlacement( + tx, + { columns, definition, table }, + { + id, + values: patch, + }, + ); await resolveContentContainerAssignment(tx, definition, patch); // The version guard runs **first**, before a single junction or child diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 3613c58d20..865d88caf7 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -1,10 +1,29 @@ export { escapeLikePattern } from "../../lib/api/like-pattern"; +export { + ContentSubmissionDecisionError, + decideContentSubmission, +} from "../approvals/server/decide"; +export type { ContentSubmissionDecision } from "../approvals/server/decide"; +export { + cancelContentSubmissions, + findLatestContentSubmission, + findPendingContentSubmission, +} from "../approvals/server/submissions"; +export type { ContentSubmissionRow } from "../approvals/server/submissions"; export { buildContentContainerAdminRoutes, zodContentContainerError, } from "../containers/server/admin-routes"; export { resolveContentContainerAssignment } from "../containers/server/assignment"; export type { ResolvedContentContainerContext } from "../containers/server/assignment"; +export { + canModerateContent, + contentContainerScopeResources, + effectiveContentPolicy, + resolveContentRowContainer, + resolveContentWriteContainer, +} from "../containers/server/policy"; +export type { ContentRecordContainer } from "../containers/server/policy"; export { buildContentContainerPublicRoutes } from "../containers/server/public-routes"; export { contentContainerReferencingModels, @@ -188,6 +207,7 @@ export type { ContentPublicRoutes, } from "./public-module"; export { buildContentPublicRoutes } from "./public-routes"; +export { resolveContentPublicRowPeople } from "./public-row-hydration"; export { contentPublicSelection, createContentPublicProjector, @@ -319,13 +339,19 @@ export type { ContentSlugHistoryModel, ContentSlugHistoryTarget, } from "./slug-history-model"; -export { createSlugNormalizer } from "./slugs"; +export { createSlugNormalizer, generateContentSlug } from "./slugs"; export type { ContentSlugNormalizer } from "./slugs"; export { assertContentReferences, contentTableColumns, createContentTable, } from "./table"; +export { + assertContentThreadPlacement, + ContentThreadPlacementError, + readContentThread, +} from "./thread"; +export type { ContentThreadNode, ContentThreadPage } from "./thread"; export { createContentTranslationEditorialService } from "./translation-editorial-service"; export type { ContentRevisionDetailForLocale, @@ -394,3 +420,7 @@ export { contentVisibilityRevalidation, } from "./visibility-effects"; export type { ContentVisibilityEffectsResult } from "./visibility-effects"; +export { + contentEditorialWriteEffects, + contentPlainWriteEffects, +} from "./write-effects"; diff --git a/packages/vitnode/src/content/server/localized-public-service.ts b/packages/vitnode/src/content/server/localized-public-service.ts index 8858d798c3..b9e9fa9753 100644 --- a/packages/vitnode/src/content/server/localized-public-service.ts +++ b/packages/vitnode/src/content/server/localized-public-service.ts @@ -245,6 +245,7 @@ export const createContentLocalizedPublicService = < // "this translation row". A reader has no notion of a translation to // attribute a timestamp to. ...Object.fromEntries(exposedShared.map(name => [name, columns[name]])), + ...Object.fromEntries(publicApi.people.map(name => [name, columns[name]])), ...Object.fromEntries( exposedLocalized.map(name => [name, localizedValue(name, withFallback)]), ), diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index 089f6ab8c0..5657da6b92 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -54,6 +54,7 @@ import { } from "./page-cache"; import { contentPreviewSecret } from "./preview-link"; import { verifyContentPreviewToken } from "./preview-token"; +import { resolveContentPublicRowPeople } from "./public-row-hydration"; import { contentPublicSelection, createContentPublicProjector, @@ -63,6 +64,7 @@ import { contentSnapshotRow, projectTranslationRevisionSnapshot, } from "./revision-snapshot"; +import { readContentThread } from "./thread"; export const buildContentPublicRoutes = < TDefinition extends AnyContentTypeDefinition, @@ -492,9 +494,13 @@ export const buildContentPublicRoutes = < // The same file resolution the published reads do, through the same // function: a preview that showed a bare `core_files.id` where the live // page shows a descriptor would be previewing a different response. - const [withFiles] = await resolveContentPublicRowFiles(c, definition, [ - { ...row, ...translated }, - ]); + const [withFiles] = await resolveContentPublicRowPeople( + c, + definition, + await resolveContentPublicRowFiles(c, definition, [ + { ...row, ...translated }, + ]), + ); return c.json( { @@ -511,6 +517,96 @@ export const buildContentPublicRoutes = < }, }); + const threadNode = z.object({ + depth: z.number(), + id: schemas.params.shape.id, + parentId: schemas.params.shape.id.nullable(), + record: schemas.publicSelectObject.nullable(), + removed: z.boolean(), + }); + + const thread = buildRoute({ + pluginId, + route: { + method: "get", + path: "/thread", + description: `Read the ${name} thread under one root record: a page of top-level ${name} records, oldest first, each followed by every public answer below it`, + request: { + query: z.object({ + root: z.string().min(1).max(64), + first: z.coerce + .number() + .int() + .min(1) + .max(CONTENT_PUBLIC_MAX_PAGE_SIZE) + .default(20), + after: z.string().max(512).optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + items: z.array(threadNode), + pageInfo: z.object({ + endCursor: z.string().nullable(), + hasNextPage: z.boolean(), + totalCount: z.number(), + }), + replyCount: z.number(), + truncated: z.boolean(), + }), + }, + }, + description: "One page of the thread, flattened depth first", + }, + 404: { description: "The root record does not exist or is not public" }, + }, + }, + handler: async c => { + const { after, first, root } = c.req.valid("query"); + const rootField = definition.fields[definition.thread.rootField]; + const target = + rootField?.kind === "relation" ? rootField.target() : undefined; + const rootModel = (c.get("core")?.contentModels ?? []).find( + entry => entry.model.definition.id === target?.id, + )?.model; + const rootId = target ? parseContentId(target.idStrategy, root) : null; + const rootRecord = + rootId === null + ? null + : await rootModel?.publicService?.(c).findById(rootId); + if (!rootRecord || rootId === null) { + throw new HTTPException(404, { message: "Thread not found." }); + } + + const read = service(c).findManyByIds; + const page = await readContentThread(c, model, { + after, + first, + project: async ids => { + const rows = read ? await read(ids as never) : []; + + return new Map( + rows.map(row => [ + String((row as unknown as { id: unknown }).id), + row, + ]), + ); + }, + root: rootId, + }); + + setPageCacheTags(c, [ + ...contentListPageCacheTags(definition), + ...(target ? contentListPageCacheTags(target) : []), + ]); + + return c.json(page, 200); + }, + }); + const detail = buildRoute({ pluginId, route: { @@ -589,6 +685,7 @@ export const buildContentPublicRoutes = < ...(definition.delivery.enabled ? buildContentDeliveryRoutes(model, { pluginId }) : []), + ...(definition.thread.enabled ? [thread] : []), detail, ...contentPublicWriteRoutes(model, { pluginId }), ]; diff --git a/packages/vitnode/src/content/server/public-row-hydration.ts b/packages/vitnode/src/content/server/public-row-hydration.ts index 57f5a10f4e..fe9e92b565 100644 --- a/packages/vitnode/src/content/server/public-row-hydration.ts +++ b/packages/vitnode/src/content/server/public-row-hydration.ts @@ -4,10 +4,41 @@ import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentAdvancedStore } from "./advanced-store"; +import { loadPublicPeople } from "../../api/lib/public-people"; import { isContentId } from "../ids"; import { splitContentFieldPath } from "../paths"; import { resolveContentPublicRowFiles } from "./files"; +/** + * Swaps each `publicApi.people` user id for its public summary, or `null`. + * + * One query for the whole page, whatever number of people fields and rows. + */ +export const resolveContentPublicRowPeople = async ( + c: Context, + definition: AnyContentTypeDefinition, + rows: readonly Record[], +): Promise[]> => { + const { people } = definition.publicApi; + if (people.length === 0 || rows.length === 0) return [...rows]; + + const loaded = await loadPublicPeople( + c, + rows.flatMap(row => people.map(name => row[name])), + ); + + return rows.map(row => ({ + ...row, + ...Object.fromEntries( + people.map(name => { + const id = row[name]; + + return [name, typeof id === "number" ? (loaded.get(id) ?? null) : null]; + }), + ), + })); +}; + /** * Puts a group's leaves back under their owner. * @@ -46,9 +77,9 @@ export const nestContentPublicRow = ( * exposes, because querying a private junction table to discard its rows * afterwards is work with no answer attached; * 3. attach them to their rows; - * 4. batch-resolve the file fields **last**, because a `multiple: true` file - * field has no column: its identifiers only exist on the row once - * `loadMany` has put them there. + * 4. batch-resolve the file fields, because a `multiple: true` file field + * has no column: its identifiers only exist on the row once `loadMany` has + * put them there - and then the `publicApi.people` summaries. * * One batch per page either way. Shared by the localized public service and the * plain one, whose statements and localization strategies are deliberately @@ -95,9 +126,9 @@ export const createContentPublicRowHydrator = // The identifier is replaced by the descriptor here rather than in the // projector, so the projector stays the one place that decides *what* is // public and this stays the one place that decides what it looks like. - return await resolveContentPublicRowFiles( + return await resolveContentPublicRowPeople( c, definition, - withCollectionValues, + await resolveContentPublicRowFiles(c, definition, withCollectionValues), ); }; diff --git a/packages/vitnode/src/content/server/public-service.test.ts b/packages/vitnode/src/content/server/public-service.test.ts index c7deaf5dfd..e1b5c25d9b 100644 --- a/packages/vitnode/src/content/server/public-service.test.ts +++ b/packages/vitnode/src/content/server/public-service.test.ts @@ -121,6 +121,7 @@ describe("model.publicService", () => { "findById", "findBySlug", "findMany", + "findManyByIds", ]); }); }); diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index c6fc1f3c60..d76fee1ccc 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 } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import type { PaginationCursorColumn } from "../../api/lib/with-pagination"; import type { ContentIdOf } from "../ids"; @@ -28,6 +28,7 @@ import { import { ContentEngineError } from "../errors"; import { contentRelationStrategy, parseContentId } from "../ids"; import { isContentReferenceCollection, splitContentFieldPath } from "../paths"; +import { isContentPublicPerson } from "../public-person"; import { publicOrderableColumns } from "../registry"; import { groupPublicLeafPaths } from "../schemas"; import { createContentPublicRowHydrator } from "./public-row-hydration"; @@ -91,6 +92,14 @@ export interface ContentPublicService { edges: ContentPublicListRow[]; pageInfo: ContentPageInfo; }>; + /** + * The published rows among `ids`, in no particular order - for a caller that + * already knows which records it wants, such as a thread. Unpublished and + * hidden ids are simply missing. Not available on localized content types. + */ + findManyByIds?: ( + ids: readonly ContentIdOf[], + ) => Promise[]>; } export const createContentPublicProjector = < @@ -108,6 +117,7 @@ export const createContentPublicProjector = < } const exposed = publicApi.fields; + const { people } = publicApi; const exposesId = exposed.includes("id"); const flat = exposed.filter(name => splitContentFieldPath(name) === null); // A `user` field is never exposable, so this is only ever relations. @@ -177,6 +187,13 @@ export const createContentPublicProjector = < : pick(value as Record, leaves); } + // Only ever the hydrated summary: a bare user id that skipped hydration + // is dropped rather than published. + for (const name of people) { + const person = row[name]; + projected[name] = isContentPublicPerson(person) ? person : null; + } + if (exposesId) projected.id = row.id; return projected as ContentPublicSelect; @@ -194,6 +211,11 @@ export const contentPublicSelection = ( columns: Record, ): Record => ({ id: columns.id, + // The user ids behind `publicApi.people`; the hydrator swaps each for a + // public summary and the projector never lets a bare id through. + ...Object.fromEntries( + definition.publicApi.people.map(name => [name, columns[name]]), + ), ...Object.fromEntries( definition.publicApi.fields // A collection has no column, and a repeatable leaf is a column on a @@ -323,6 +345,20 @@ export const createContentPublicService = < findBySlug: async (slug, options) => await readOne(eq(columns[publicApi.slugField], slug), options), + findManyByIds: async ids => { + if (ids.length === 0) return []; + + const rows = await c + .get("db") + .select(selection()) + .from(table) + .where( + and(publishedCondition(published), inArray(primaryCursor, [...ids])), + ); + + return (await withCollections(rows)).map(project); + }, + findMany: async ({ filters = {}, orderBy, query = {} } = {}) => { const conditions = [ // Not optional, not a parameter, and first: whatever else a caller diff --git a/packages/vitnode/src/content/server/public-write-authorization.ts b/packages/vitnode/src/content/server/public-write-authorization.ts index 155f356a25..8989de77ef 100644 --- a/packages/vitnode/src/content/server/public-write-authorization.ts +++ b/packages/vitnode/src/content/server/public-write-authorization.ts @@ -1,10 +1,13 @@ import type { Context } from "hono"; +import type { EffectiveContentContainerPolicy } from "../containers/policies"; +import type { ContentRecordContainer } from "../containers/server/policy"; import type { ContentActor } from "../revisions"; import type { AnyContentTypeDefinition } from "../types"; -import { checkStaffPermission } from "../../api/lib/check-staff-permission"; import { CONTENT_PERMISSIONS } from "../const"; +import { contentAuthorMayEdit } from "../containers/policies"; +import { canModerateContent } from "../containers/server/policy"; import { isContentBaseRowPublic } from "./visibility"; export interface ContentPublicWriteActor extends ContentActor { @@ -33,41 +36,63 @@ export const contentPublicWriteOwnerOf = ( export interface ContentPublicWriteAuthorizationInput { action: "update"; actor: ContentPublicWriteActor; + container?: ContentRecordContainer | null; definition: AnyContentTypeDefinition; pluginId: string; + policy?: EffectiveContentContainerPolicy | null; row: Record; } export type ContentPublicWriteAuthorization = - | { allowed: false; via: null } + | { allowed: false; reason: "editWindowClosed" | null; via: null } | { allowed: true; via: "moderator" | "owner" }; +/** + * 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`. + */ export const authorizeContentPublicWrite = async ( c: Context, - { actor, definition, pluginId, row }: ContentPublicWriteAuthorizationInput, + { + actor, + container = null, + definition, + pluginId, + policy = null, + row, + }: ContentPublicWriteAuthorizationInput, ): Promise => { const { update } = definition.publicWrite; - if (!update.enabled) return { allowed: false, via: null }; + if (!update.enabled) return { allowed: false, reason: null, via: null }; - const hidden = row.hiddenAt !== null && row.hiddenAt !== undefined; if ( + await canModerateContent(c, { + container, + definition, + permission: CONTENT_PERMISSIONS.edit, + pluginId, + }) + ) { + return { allowed: true, via: "moderator" }; + } + + const hidden = row.hiddenAt !== null && row.hiddenAt !== undefined; + const owns = update.own && !hidden && - contentPublicWriteOwnerOf(definition, row) === actor.userId + contentPublicWriteOwnerOf(definition, row) === actor.userId; + if (!owns) return { allowed: false, reason: null, via: null }; + + if ( + policy && + !contentAuthorMayEdit(policy.policy, row.createdAt as Date | undefined) ) { - return { allowed: true, via: "owner" }; + return { allowed: false, reason: "editWindowClosed", via: null }; } - const moderator = await checkStaffPermission(c, { - module: definition.permissionModule, - permission: CONTENT_PERMISSIONS.edit, - plugin: pluginId, - type: "moderator", - }); - - return moderator - ? { allowed: true, via: "moderator" } - : { allowed: false, via: null }; + return { allowed: true, via: "owner" }; }; export const contentPublicWriteRefusal = ( diff --git a/packages/vitnode/src/content/server/public-write-policies.test.ts b/packages/vitnode/src/content/server/public-write-policies.test.ts new file mode 100644 index 0000000000..22041712c2 --- /dev/null +++ b/packages/vitnode/src/content/server/public-write-policies.test.ts @@ -0,0 +1,393 @@ +import { describe, expect, it } from "vitest"; + +import { core_content_revisions } from "@/database/content"; +import { + BOARD, + createPolicyWorld, + policyReplies, + policyReplyRow, + policyTopicRow, + policyTopics, + USER, +} from "@/tests/content-policies"; + +import type { ContentPublicWriteRejection } from "../conflicts"; + +import { + CONTENT_PUBLIC_WRITE_CODES, + CONTENT_PUBLIC_WRITE_ISSUE_CODES, +} from "../const"; + +const TOPIC = { + APPROVE: 30, + CHILD: 20, + LOCKED: 50, + OPEN: 10, + OTHER: 11, +} as const; + +const REPLY = { OLD: 100, OTHER_ROOT: 110, RECENT: 101 } as const; + +const world = async () => + await createPolicyWorld({ + replies: [ + policyReplyRow(REPLY.OLD, TOPIC.OPEN), + policyReplyRow(REPLY.RECENT, TOPIC.OPEN, { createdAt: new Date() }), + policyReplyRow(REPLY.OTHER_ROOT, TOPIC.OTHER), + ], + topics: [ + policyTopicRow(TOPIC.OPEN, BOARD.OPEN), + policyTopicRow(TOPIC.OTHER, BOARD.OPEN), + policyTopicRow(TOPIC.CHILD, BOARD.OPEN_CHILD), + policyTopicRow(TOPIC.APPROVE, BOARD.APPROVE), + policyTopicRow(TOPIC.LOCKED, BOARD.LOCKED), + ], + }); + +const rejection = async ( + response: Response, +): Promise => + (await response.json()) as ContentPublicWriteRejection; + +interface WriteRecord { + approval: null | { + id: number; + kind: string; + status: string; + values: null | Record; + }; + id: number; + slug: string; + status: string; + values: Record; + version?: number; +} + +const topicIn = (board: number) => ({ + values: { board, title: "Hello world" }, +}); + +describe("creating in a board with policies", () => { + it("publishes at once in an immediate board", async () => { + const { submissions, send } = await world(); + + const response = await send("POST", "/topics", { + body: topicIn(BOARD.OPEN), + user: USER.MEMBER, + }); + + expect(response.status).toBe(201); + await expect(response.json()).resolves.toMatchObject({ + approval: null, + status: "published", + }); + expect(submissions()).toEqual([]); + }); + + it("stores a draft the public cannot read and opens a submission in an approve board", async () => { + const { emitted, events, send, submissions } = await world(); + + const response = await send("POST", "/topics", { + body: topicIn(BOARD.APPROVE), + user: USER.MEMBER, + }); + + expect(response.status).toBe(201); + const record = (await response.json()) as WriteRecord; + expect(record).toMatchObject({ + approval: { kind: "create", status: "pending" }, + publishedAt: null, + status: "draft", + }); + expect(submissions()).toEqual([ + expect.objectContaining({ + authorId: USER.MEMBER, + containerId: BOARD.APPROVE, + itemId: String(record.id), + kind: "create", + status: "pending", + }), + ]); + expect(events()).not.toContain("content.test.policy-topic.published"); + expect(emitted).toContainEqual({ + name: "content.approval.submitted", + payload: expect.objectContaining({ + contentId: String(record.id), + submissionId: record.approval?.id, + }), + }); + + expect((await send("GET", `/topics/${record.slug}`)).status).toBe(404); + const list = (await (await send("GET", "/topics")).json()) as { + edges: { id: number }[]; + }; + expect(list.edges.map(edge => edge.id)).toContain(TOPIC.OPEN); + expect(list.edges.map(edge => edge.id)).not.toContain(record.id); + }); + + it.each([ + ["holds can_approve in the board", USER.MOD_APPROVE], + ["is a root moderator", USER.ROOT], + ])("publishes at once when the author %s", async (_label, user) => { + const { send, submissions } = await world(); + + const response = await send("POST", "/topics", { + body: topicIn(BOARD.APPROVE), + user, + }); + + expect(response.status).toBe(201); + await expect(response.json()).resolves.toMatchObject({ + approval: null, + status: "published", + }); + expect(submissions()).toEqual([]); + }); + + it.each([ + ["a member", USER.MEMBER, 403], + ["a moderator of another board", USER.MOD_OPEN, 403], + ["a root moderator", USER.ROOT, 201], + ])( + "lets only can_edit holders start a topic in a moderators board: %s gets %i", + async (_label, user, status) => { + const { events, rows, send } = await world(); + const before = rows(policyTopics.table); + + const response = await send("POST", "/topics", { + body: topicIn(BOARD.MODERATORS), + user, + }); + + expect(response.status).toBe(status); + if (status === 201) return; + await expect(rejection(response)).resolves.toEqual({ + code: CONTENT_PUBLIC_WRITE_CODES.forbidden, + contentTypeId: policyTopics.definition.id, + issues: [ + { + code: CONTENT_PUBLIC_WRITE_ISSUE_CODES.moderatorsOnly, + field: null, + message: expect.any(String), + }, + ], + }); + expect(rows(policyTopics.table)).toEqual(before); + expect(events()).toEqual([]); + }, + ); +}); + +describe("an author editing a published record in an approve board", () => { + const propose = async ( + send: Awaited>["send"], + title: string, + ) => + await send("PATCH", `/topics/${TOPIC.APPROVE}`, { + body: { expectedVersion: 1, values: { title } }, + user: USER.MEMBER, + }); + + it("stores a proposal and leaves the live row and its version alone", async () => { + const { events, rows, send, submissions } = await world(); + const before = rows(policyTopics.table); + + const response = await propose(send, "Proposed title"); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + approval: { + kind: "edit", + status: "pending", + values: { title: "Proposed title" }, + }, + values: { title: `Topic ${TOPIC.APPROVE}` }, + version: 1, + }); + expect(rows(policyTopics.table)).toEqual(before); + expect(rows(core_content_revisions)).toEqual([]); + expect(submissions()).toEqual([ + expect.objectContaining({ + baseValues: { board: BOARD.APPROVE, title: `Topic ${TOPIC.APPROVE}` }, + baseVersion: 1, + kind: "edit", + status: "pending", + values: { title: "Proposed title" }, + }), + ]); + expect(events()).toEqual(["content.approval.submitted"]); + }); + + it("replaces a pending proposal with the newer one", async () => { + const { emitted, send, submissions } = await world(); + + await propose(send, "First proposal"); + const response = await propose(send, "Second proposal"); + + expect(response.status).toBe(200); + const [first, second] = submissions(); + expect(first).toMatchObject({ status: "superseded" }); + expect(second).toMatchObject({ + status: "pending", + values: { title: "Second proposal" }, + }); + expect(emitted.at(-1)).toEqual({ + name: "content.approval.submitted", + payload: expect.objectContaining({ + replacedSubmissionId: first?.id, + submissionId: second?.id, + }), + }); + }); +}); + +describe("the author edit window", () => { + it.each([ + [ + "a topic in a board where authors never edit", + `/topics/${TOPIC.LOCKED}`, + { expectedVersion: 1, values: { title: "Too late" } }, + 403, + ], + [ + "a reply older than its window", + `/replies/${REPLY.OLD}`, + { values: { body: "Too late" } }, + 403, + ], + [ + "a reply still inside its window", + `/replies/${REPLY.RECENT}`, + { values: { body: "Just in time" } }, + 200, + ], + ])( + "answers an author editing %s with %i", + async (_label, path, body, status) => { + const { events, memory, send } = await world(); + const before = [ + memory.rows(policyTopics.table), + memory.rows(policyReplies.table), + ]; + + const response = await send("PATCH", path, { body, user: USER.MEMBER }); + + expect(response.status).toBe(status); + if (status === 200) return; + await expect(rejection(response)).resolves.toMatchObject({ + code: CONTENT_PUBLIC_WRITE_CODES.forbidden, + issues: [ + { + code: CONTENT_PUBLIC_WRITE_ISSUE_CODES.editWindowClosed, + field: null, + }, + ], + }); + expect([ + memory.rows(policyTopics.table), + memory.rows(policyReplies.table), + ]).toEqual(before); + expect(events()).toEqual([]); + }, + ); +}); + +describe("a moderator with can_edit scoped to one board", () => { + it.each([ + ["their board", TOPIC.OPEN, 200], + ["a board below theirs (a plain id is not a subtree)", TOPIC.CHILD, 403], + ["another board", TOPIC.APPROVE, 403], + ])("editing a member's topic in %s gets %i", async (_label, id, status) => { + const { rows, send } = await world(); + + const response = await send("PATCH", `/topics/${id}`, { + body: { expectedVersion: 1, values: { title: "Moderated title" } }, + user: USER.MOD_OPEN, + }); + + expect(response.status).toBe(status); + expect(rows(policyTopics.table).find(row => row.id === id)?.title).toBe( + status === 200 ? "Moderated title" : `Topic ${id}`, + ); + }); + + it("edits a reply in their board after the author's window closed", async () => { + const { send } = await world(); + + const response = await send("PATCH", `/replies/${REPLY.OLD}`, { + body: { values: { body: "Moderated" } }, + user: USER.MOD_OPEN, + }); + + expect(response.status).toBe(200); + }); +}); + +describe("a reply inherits its topic's board", () => { + it.each([ + ["an immediate board", TOPIC.OPEN, "published", []], + [ + "an approve board", + TOPIC.APPROVE, + "draft", + [{ containerId: BOARD.APPROVE, kind: "create", status: "pending" }], + ], + ])("applies the policy of %s", async (_label, topic, status, expected) => { + const { send, submissions } = await world(); + + const response = await send("POST", "/replies", { + body: { values: { body: "An answer", topic } }, + user: USER.MEMBER, + }); + + expect(response.status).toBe(201); + await expect(response.json()).resolves.toMatchObject({ status }); + expect(submissions()).toEqual( + expected.map(entry => expect.objectContaining(entry)), + ); + }); + + it("refuses a board sent by the client", async () => { + const { send } = await world(); + + const response = await send("POST", "/replies", { + body: { + values: { board: BOARD.OPEN, body: "An answer", topic: TOPIC.APPROVE }, + }, + user: USER.MEMBER, + }); + + expect(response.status).toBe(400); + expect((await rejection(response)).issues).toContainEqual( + expect.objectContaining({ + code: CONTENT_PUBLIC_WRITE_ISSUE_CODES.notWritable, + field: "board", + }), + ); + }); +}); + +describe("thread placement", () => { + it("refuses a parent from another topic, on the parent field", async () => { + const { events, rows, send } = await world(); + const before = rows(policyReplies.table); + + const response = await send("POST", "/replies", { + body: { + values: { + body: "Wrong place", + parent: REPLY.OTHER_ROOT, + topic: TOPIC.OPEN, + }, + }, + user: USER.MEMBER, + }); + + expect(response.status).toBe(400); + expect((await rejection(response)).issues).toEqual([ + expect.objectContaining({ field: "parent" }), + ]); + expect(rows(policyReplies.table)).toEqual(before); + expect(events()).toEqual([]); + }); +}); 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 d31cd3b1fd..9bfada89fc 100644 --- a/packages/vitnode/src/content/server/public-write-routes.test.ts +++ b/packages/vitnode/src/content/server/public-write-routes.test.ts @@ -402,6 +402,7 @@ describe("GET /{id}/edit", () => { expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("private, no-store"); await expect(response.json()).resolves.toEqual({ + approval: null, id: TOPIC.DRAFT, publishedAt: null, slug: `topic-${TOPIC.DRAFT}`, diff --git a/packages/vitnode/src/content/server/public-write-routes.ts b/packages/vitnode/src/content/server/public-write-routes.ts index 1c3d5eb0c4..fb93ca24e6 100644 --- a/packages/vitnode/src/content/server/public-write-routes.ts +++ b/packages/vitnode/src/content/server/public-write-routes.ts @@ -93,7 +93,9 @@ const toHttpError = ( throw reject(STATUS_OF[error.reason], { code: CODE_OF[error.reason], contentTypeId, - issues: [], + issues: error.issue + ? [{ code: error.issue, field: null, message: error.message }] + : [], }); } diff --git a/packages/vitnode/src/content/server/public-write-service.ts b/packages/vitnode/src/content/server/public-write-service.ts index 643bc33d66..451e6b483b 100644 --- a/packages/vitnode/src/content/server/public-write-service.ts +++ b/packages/vitnode/src/content/server/public-write-service.ts @@ -8,7 +8,9 @@ import type { Context } from "hono"; import { eq, getColumns, getColumnTable, inArray } from "drizzle-orm"; import { getTableConfig } from "drizzle-orm/pg-core"; -import type { ResolvedContentContainerContext } from "../containers/server/assignment"; +import type { ContentSubmissionRow } from "../approvals/server/submissions"; +import type { EffectiveContentContainerPolicy } from "../containers/policies"; +import type { ContentRecordContainer } from "../containers/server/policy"; import type { ContentId, ContentIdOf } from "../ids"; import type { AnyContentTypeDefinition, @@ -17,30 +19,52 @@ import type { ContentPublicUpdateBody, ContentPublicWriteRecord, } from "../types"; -import type { ContentEditorialOutcome } from "./editorial-service"; import type { ContentModel } from "./model"; import type { ContentPublicWriteActor } from "./public-write-authorization"; import type { ContentDatabase } from "./service"; -import { ContentContainerAssignmentError } from "../containers/association"; -import { resolveContentContainerAssignment } from "../containers/server/assignment"; +import { + emitContentApprovalSubmitted, + findLatestContentSubmission, + findPendingContentSubmission, + openContentSubmission, + refreshContentSubmissionValues, + toContentApprovalState, +} from "../approvals/server/submissions"; +import { + CONTENT_PERMISSIONS, + CONTENT_PUBLIC_WRITE_ISSUE_CODES, +} from "../const"; +import { + ContentContainerAssignmentError, + contentContainerSourceOf, +} from "../containers/association"; +import { + canModerateContent, + effectiveContentPolicy, + resolveContentRowContainer, + resolveContentWriteContainer, +} from "../containers/server/policy"; import { ContentEngineError, ContentPublicWriteDenied, ContentPublicWriteInputError, + ContentVersionConflict, } from "../errors"; import { contentIdKey, requireContentId } from "../ids"; import { isContentReferenceCollection } from "../paths"; -import { contentEditorialEffects } from "./editorial-effects"; -import { emitContentEvent } from "./emit"; import { authorizeContentPublicWrite, contentPublicWriteRefusal, resolveContentPublicWriteActor, } from "./public-write-authorization"; import { publicationMethods } from "./publication"; -import { contentSearchAdvancedValues, syncContentSearch } from "./search-sync"; +import { ContentThreadPlacementError } from "./thread"; import { isContentBaseRowPublic } from "./visibility"; +import { + contentEditorialWriteEffects, + contentPlainWriteEffects, +} from "./write-effects"; export interface ContentPublicWriteService { create: ( @@ -57,15 +81,71 @@ export interface ContentPublicWriteService { export interface ContentPublicationPolicyInput { actor: ContentPublicWriteActor; - container: null | ResolvedContentContainerContext; + c: Context; + container: ContentRecordContainer | null; definition: AnyContentTypeDefinition; operation: "create"; + pluginId: string; } -export const resolveContentPublicationPolicy = ({ +export interface ContentPublicationDecision { + /** The record waits for a moderator: it is stored as a draft with a submission. */ + approval: boolean; + policy: EffectiveContentContainerPolicy | null; + status: ContentPublicationStatus; +} + +/** + * How a member's new record is published - decided on the server from the + * container it is filed in, never from the payload. + * + * Without container policies it is the content type's own + * `publicWrite.create.status`. With them, `publish: "approve"` stores a draft + * and opens a submission, unless the author may approve in that container. + */ +export const resolveContentPublicationPolicy = async ({ + c, + container, definition, -}: ContentPublicationPolicyInput): ContentPublicationStatus => - definition.publicWrite.create.status; + pluginId, +}: ContentPublicationPolicyInput): Promise => { + const policy = effectiveContentPolicy(definition, container); + if (!policy) { + return { + approval: false, + policy: null, + status: definition.publicWrite.create.status, + }; + } + + const can = async (permission: string) => + await canModerateContent(c, { + container, + definition, + permission, + pluginId, + }); + + if ( + policy.policy.create === "moderators" && + !(await can(CONTENT_PERMISSIONS.edit)) + ) { + throw new ContentPublicWriteDenied({ + contentTypeId: definition.id, + issue: CONTENT_PUBLIC_WRITE_ISSUE_CODES.moderatorsOnly, + reason: "forbidden", + }); + } + + if ( + policy.policy.publish === "immediate" || + (await can(CONTENT_PERMISSIONS.approve)) + ) { + return { approval: false, policy, status: "published" }; + } + + return { approval: true, policy, status: "draft" }; +}; export const createContentPublicWriteService = < TDefinition extends AnyContentTypeDefinition, @@ -86,6 +166,7 @@ export const createContentPublicWriteService = < const schemas = model.schemas.publicWrite; const table: PgTableWithColumns = model.table; const columns = model.columns as Record; + const containerSource = contentContainerSourceOf(definition); const readable = [ ...new Set([...publicWrite.create.fields, ...publicWrite.update.fields]), @@ -94,8 +175,10 @@ export const createContentPublicWriteService = < isContentReferenceCollection(definition.fields[name]), ); - const deny = (reason: ContentPublicWriteDenied["reason"]) => - new ContentPublicWriteDenied({ contentTypeId, reason }); + const deny = ( + reason: ContentPublicWriteDenied["reason"], + issue: ContentPublicWriteDenied["issue"] = null, + ) => new ContentPublicWriteDenied({ contentTypeId, issue, reason }); const requireActor = (): ContentPublicWriteActor => { const actor = resolveContentPublicWriteActor(c); @@ -198,17 +281,16 @@ export const createContentPublicWriteService = < } }; - const resolveContainer = async ( - tx: ContentDatabase, - values: Record, - ): Promise => { + const asInputError = async (run: () => Promise): Promise => { try { - return ( - (await resolveContentContainerAssignment(tx, definition, values)) ?? - null - ); + return await run(); } catch (error) { - if (!(error instanceof ContentContainerAssignmentError)) throw error; + if ( + !(error instanceof ContentContainerAssignmentError) && + !(error instanceof ContentThreadPlacementError) + ) { + throw error; + } throw new ContentPublicWriteInputError({ contentTypeId, @@ -219,11 +301,17 @@ export const createContentPublicWriteService = < }; const lockSelection = (): Record => ({ + createdAt: columns.createdAt, id: columns.id, publishedAt: columns.publishedAt, status: columns.status, + updatedAt: columns.updatedAt, + ...(editorial ? { version: columns.version } : {}), ...(ownerField === null ? {} : { [ownerField]: columns[ownerField] }), ...(definition.visibility.enabled ? { hiddenAt: columns.hiddenAt } : {}), + ...(containerSource && columns[containerSource.name] + ? { [containerSource.name]: columns[containerSource.name] } + : {}), }); const lockRow = async ( @@ -240,9 +328,9 @@ export const createContentPublicWriteService = < return row ?? null; }; - const toRecord = async ( + const valuesOf = async ( row: Record, - ): Promise> => { + ): Promise> => { const id = requireContentId(definition.idStrategy, row.id, contentTypeId); const collections = collectionFields.length > 0 @@ -250,99 +338,135 @@ export const createContentPublicWriteService = < .service(c) .advancedFields(id as ContentIdOf, collectionFields) : {}; + + return Object.fromEntries( + readable.map(name => [name, collections[name] ?? row[name] ?? null]), + ); + }; + + const toRecord = async ( + row: Record, + approval: ContentSubmissionRow | null = null, + ): Promise> => { + const id = requireContentId(definition.idStrategy, row.id, contentTypeId); const slugField = definition.publicApi.slugField; const slug = slugField ? row[slugField] : null; return { + approval: approval ? toContentApprovalState(approval) : null, id, publishedAt: (row.publishedAt as Date | null | undefined) ?? null, slug: typeof slug === "string" ? slug : null, status: row.status as ContentPublicationStatus, - values: Object.fromEntries( - readable.map(name => [name, collections[name] ?? row[name] ?? null]), - ), + values: await valuesOf(row), ...(editorial ? { version: row.version as number } : {}), } as unknown as ContentPublicWriteRecord; }; + /** The submission an author should see: a pending one, or a rejection nothing replaced. */ + const visibleSubmission = async ( + id: ContentId, + ): Promise => { + const latest = await findLatestContentSubmission( + c.get("db"), + definition, + id, + ); + + return latest?.status === "pending" || latest?.status === "rejected" + ? latest + : null; + }; + const authorize = async ( + tx: ContentDatabase, actor: ContentPublicWriteActor, row: null | Record, - ): Promise> => { + ) => { if (!row) throw deny("notFound"); + const container = await resolveContentRowContainer(c, tx, definition, row); + const policy = effectiveContentPolicy(definition, container); const decision = await authorizeContentPublicWrite(c, { action: "update", actor, + container, definition, pluginId, + policy, row, }); - if (!decision.allowed) throw deny(contentPublicWriteRefusal(row)); + if (!decision.allowed) { + throw deny( + contentPublicWriteRefusal(row), + decision.reason === "editWindowClosed" && + contentPublicWriteRefusal(row) === "forbidden" + ? CONTENT_PUBLIC_WRITE_ISSUE_CODES.editWindowClosed + : null, + ); + } - return row; + return { container, policy, row, via: decision.via }; }; - const plainEffects = async ( - operation: "create" | "update", - row: Record, + const canApprove = async (container: ContentRecordContainer | null) => + await canModerateContent(c, { + container, + definition, + permission: CONTENT_PERMISSIONS.approve, + pluginId, + }); + + /** + * After an applied write to a record that is not public yet: a pending + * create follows the draft, and a draft in an approval container that has + * no pending submission is (re)submitted. + */ + const syncDraftSubmission = async ( + tx: ContentDatabase, { - changedFields = [], - published = false, - }: { changedFields?: string[]; published?: boolean } = {}, - ): Promise => { + actor, + container, + policy, + row, + via, + }: { + actor: ContentPublicWriteActor; + container: ContentRecordContainer | null; + policy: EffectiveContentContainerPolicy | null; + row: Record; + via: "moderator" | "owner"; + }, + ): Promise => { + if (!policy || row.status === "published") return null; + const id = row.id as ContentId; + const pending = await findPendingContentSubmission(tx, definition, id, { + lock: true, + }); + if (pending) { + await refreshContentSubmissionValues(tx, pending.id, await valuesOf(row)); - if (operation === "create") { - await emitContentEvent( - c, - definition, - "created", - { contentId: id }, - { pluginId }, - ); - if (published && row.publishedAt instanceof Date) { - await emitContentEvent( - c, - definition, - "published", - { contentId: id, publishedAt: row.publishedAt }, - { pluginId }, - ); - } - } else if (changedFields.length > 0) { - await emitContentEvent( - c, - definition, - "updated", - { changedFields, contentId: id }, - { pluginId }, - ); + return null; } + if (via !== "owner" || policy.policy.publish !== "approve") return null; + if (await canApprove(container)) return null; - await syncContentSearch(c, definition, { - advanced: await contentSearchAdvancedValues( - c, - model, - requireContentId(definition.idStrategy, id, contentTypeId), - ), - ...(operation === "update" ? { changedFields } : {}), - operation, + return await openContentSubmission(tx, { + authorId: actor.userId, + container, + definition, + itemId: id, + kind: "create", pluginId, - row, + values: await valuesOf(row), }); }; - const editorialEffects = async ( - outcomes: (ContentEditorialOutcome | null)[], + const announce = async ( + opened: null | { replaced: null | number; row: ContentSubmissionRow }, ): Promise => { - for (const outcome of outcomes) { - if (!outcome) continue; - await contentEditorialEffects(c, definition, outcome, { - model, - pluginId, - }); - } + if (opened) await emitContentApprovalSubmitted(c, opened); }; return { @@ -356,58 +480,109 @@ export const createContentPublicWriteService = < const prepare = async (tx: ContentDatabase) => { await assertPublicRelationTargets(tx, parsed); - const container = await resolveContainer(tx, parsed); - - return resolveContentPublicationPolicy({ + const container = await asInputError( + async () => + await resolveContentWriteContainer(c, tx, definition, parsed), + ); + const decision = await resolveContentPublicationPolicy({ actor, + c, container, definition, operation: "create", + pluginId, }); + + return { container, decision }; }; + const submit = async ( + tx: ContentDatabase, + row: Record, + container: ContentRecordContainer | null, + ) => + await openContentSubmission(tx, { + authorId: actor.userId, + container, + definition, + itemId: row.id as ContentId, + kind: "create", + pluginId, + values: await valuesOf(row), + }); + if (editorial) { const service = editorialService(); - const { created, published } = await c + const { created, opened, published } = await c .get("db") .transaction(async tx => { - const status = await prepare(tx); - const created = await service.create(stamped as never, { - actor, - tx, - }); + const { container, decision } = await prepare(tx); + const created = await asInputError( + async () => + await service.create(stamped as never, { + actor, + tx, + }), + ); const published = - status === "published" + decision.status === "published" ? await service.publish(created.row.id as never, { actor, tx }) : null; + const opened = decision.approval + ? await submit(tx, created.row, container) + : null; - return { created, published }; + return { created, opened, published }; }); - await editorialEffects([created, published]); + await contentEditorialWriteEffects(c, model, { + outcomes: [created, published], + pluginId, + }); + await announce(opened); - return await toRecord(published?.row ?? created.row); + return await toRecord(published?.row ?? created.row, opened?.row); } const service = model.service(c); - const { row, status } = await c.get("db").transaction(async tx => { - const status = await prepare(tx); - const created = await service.create(stamped as never, { tx }); - if (status !== "published") return { row: created, status }; - - const result = await publicationMethods(definition, service).publish( - created.id as never, - { tx }, - ); - - return { row: result?.row ?? created, status }; - }); + const { opened, row, status } = await c + .get("db") + .transaction(async tx => { + const { container, decision } = await prepare(tx); + const created = await asInputError( + async () => await service.create(stamped as never, { tx }), + ); + if (decision.status !== "published") { + return { + opened: decision.approval + ? await submit(tx, created, container) + : null, + row: created, + status: decision.status, + }; + } + + const result = await publicationMethods(definition, service).publish( + created.id as never, + { tx }, + ); + + return { + opened: null, + row: result?.row ?? created, + status: decision.status, + }; + }); - await plainEffects("create", row, { + await contentPlainWriteEffects(c, model, { + operation: "create", + pluginId, published: status === "published", + row, }); + await announce(opened); - return await toRecord(row); + return await toRecord(row, opened?.row); }, findEditable: async id => { @@ -420,12 +595,12 @@ export const createContentPublicWriteService = < .from(table) .where(eq(columns.id, id)) .limit(1); - await authorize(actor, row ?? null); + await authorize(c.get("db"), actor, row ?? null); const full = await model.service(c).findById(id); if (!full) throw deny("notFound"); - return await toRecord(full); + return await toRecord(full, await visibleSubmission(id)); }, update: async (id, body) => { @@ -436,39 +611,151 @@ export const createContentPublicWriteService = < values: Record; }; + /** + * An owner's edit of a published record in a container that reviews + * edits becomes a proposal. The live row - and its version - stay as + * they are until a moderator approves. + */ + const propose = async ( + tx: ContentDatabase, + { + container, + row, + }: { + container: ContentRecordContainer | null; + row: Record; + }, + ) => { + if (editorial && (expectedVersion ?? 0) !== row.version) { + throw new ContentVersionConflict({ + contentTypeId, + currentVersion: row.version as number, + expectedVersion: expectedVersion ?? 0, + itemId: id, + }); + } + + const full = await model.service(c).findById(id); + if (!full) throw deny("notFound"); + const base = await valuesOf(full); + const proposed = Object.fromEntries( + publicWrite.update.fields.map(name => [ + name, + Object.hasOwn(values, name) ? values[name] : (base[name] ?? null), + ]), + ); + + const opened = await openContentSubmission(tx, { + authorId: actor.userId, + baseUpdatedAt: row.updatedAt as Date, + baseValues: base, + baseVersion: editorial ? (row.version as number) : null, + container, + definition, + itemId: id, + kind: "edit", + pluginId, + values: proposed, + }); + + return { full, opened }; + }; + + const needsReview = async ({ + container, + policy, + row, + via, + }: Awaited>) => + via === "owner" && + policy?.policy.edits === "approve" && + row.status === "published" && + !(await canApprove(container)); + if (editorial) { const service = editorialService(); - const outcome = await c.get("db").transaction(async tx => { - await authorize(actor, await lockRow(tx, id)); + const result = await c.get("db").transaction(async tx => { + const access = await authorize(tx, actor, await lockRow(tx, id)); await assertPublicRelationTargets(tx, values); - return await service.update(id, values as never, { + if (await needsReview(access)) { + return { + kind: "proposal" as const, + ...(await propose(tx, access)), + }; + } + + const outcome = await service.update(id, values as never, { actor, expectedVersion: expectedVersion ?? 0, tx, }); + if (!outcome) throw deny("notFound"); + + return { + kind: "applied" as const, + opened: await syncDraftSubmission(tx, { + ...access, + actor, + row: outcome.row, + }), + outcome, + }; }); - if (!outcome) throw deny("notFound"); - await editorialEffects([outcome]); + if (result.kind === "proposal") { + await announce(result.opened); + + return await toRecord(result.full, result.opened.row); + } + + await contentEditorialWriteEffects(c, model, { + outcomes: [result.outcome], + pluginId, + }); + await announce(result.opened); - return await toRecord(outcome.row); + return await toRecord(result.outcome.row, await visibleSubmission(id)); } const service = model.service(c); const result = await c.get("db").transaction(async tx => { - await authorize(actor, await lockRow(tx, id)); + const access = await authorize(tx, actor, await lockRow(tx, id)); await assertPublicRelationTargets(tx, values); - return await service.update(id, values as never, { tx }); + if (await needsReview(access)) { + return { kind: "proposal" as const, ...(await propose(tx, access)) }; + } + + const updated = await service.update(id, values as never, { tx }); + if (!updated) throw deny("notFound"); + + return { + kind: "applied" as const, + opened: await syncDraftSubmission(tx, { + ...access, + actor, + row: updated.row, + }), + updated, + }; }); - if (!result) throw deny("notFound"); - await plainEffects("update", result.row, { - changedFields: result.changedFields, + if (result.kind === "proposal") { + await announce(result.opened); + + return await toRecord(result.full, result.opened.row); + } + + await contentPlainWriteEffects(c, model, { + changedFields: result.updated.changedFields, + operation: "update", + pluginId, + row: result.updated.row, }); + await announce(result.opened); - return await toRecord(result.row); + return await toRecord(result.updated.row, await visibleSubmission(id)); }, }; }; diff --git a/packages/vitnode/src/content/server/references.ts b/packages/vitnode/src/content/server/references.ts index d4c5306c2a..9740a3cbc6 100644 --- a/packages/vitnode/src/content/server/references.ts +++ b/packages/vitnode/src/content/server/references.ts @@ -9,7 +9,7 @@ import { alias, getTableConfig } from "drizzle-orm/pg-core"; import type { AnyContentTypeDefinition, ContentReferenceField } from "../types"; -import { ContentEngineError } from "../errors"; +import { ContentEngineError, ContentInputError } from "../errors"; import { partitionContentFields } from "../localization"; import { isContentReferenceCollection } from "../paths"; import { createContentTranslationTable } from "./translation-table"; @@ -218,3 +218,25 @@ const buildPickerTarget = ( : {}), }; }; + +/** A to-one self-relation may point at any record of its type but its own. */ +export const assertNoContentSelfReference = ( + definition: Pick, + id: unknown, + values: Record, +): void => { + for (const [name, fieldValue] of Object.entries(definition.fields)) { + if (fieldValue.kind !== "relation" || !fieldValue.self) continue; + if (fieldValue.multiple) continue; + + const value = values[name]; + if (typeof value !== "number" && typeof value !== "string") continue; + if (typeof id !== "number" && typeof id !== "string") continue; + if (String(value) !== String(id)) continue; + + throw new ContentInputError( + `Field "${name}" points at the record itself. A record cannot be its own ${name}.`, + { contentTypeId: definition.id }, + ); + } +}; diff --git a/packages/vitnode/src/content/server/search-document.ts b/packages/vitnode/src/content/server/search-document.ts index 470ac6aba0..ce7b282ecf 100644 --- a/packages/vitnode/src/content/server/search-document.ts +++ b/packages/vitnode/src/content/server/search-document.ts @@ -11,7 +11,11 @@ import { partitionContentFields } from "../localization"; import { readContentPath, splitContentFieldPath } from "../paths"; import { isRichTextDocument } from "../rich-text/document"; import { richTextToPlainText } from "../rich-text/plain-text"; -import { contentSearchUrl } from "../search"; +import { + contentSearchContextSource, + contentSearchTitleRelation, + contentSearchUrl, +} from "../search"; import { isContentHidden } from "../visibility"; /** Collapses whitespace so a multi-line value cannot break a result heading. */ @@ -87,16 +91,26 @@ const toFileId = (value: unknown): null | number => ? value : null; +export interface ContentSearchContext { + containerId: null | number; + containerType: string; + isPublic: boolean; + title: string; + url: null | string; +} + export const contentSearchDocument = ( definition: AnyContentTypeDefinition, row: object, { - isHidden = isContentRowHidden(row), - isPublic = isContentRowPublic(row), + context, + isHidden: isOwnHidden = isContentRowHidden(row), + isPublic: isOwnPublic = isContentRowPublic(row), locale, pluginId, routing, }: { + context?: ContentSearchContext | null; isHidden?: boolean; isPublic?: boolean; locale?: string; @@ -112,9 +126,18 @@ export const contentSearchDocument = ( const itemId = values.id; if (!isContentId(definition.idStrategy, itemId)) return null; - const title = normalize(readSearchValue(values, search.titleField)); + const titleRelation = contentSearchTitleRelation(definition); + const title = normalize( + titleRelation ? context?.title : readSearchValue(values, search.titleField), + ); if (title === "") return null; + const isReachable = + contentSearchContextSource(definition)?.kind !== "relation" || + context?.isPublic === true; + const isPublic = isOwnPublic && isReachable; + const isHidden = isOwnHidden && isReachable; + const url = isPublic || isHidden ? contentSearchUrl({ @@ -171,9 +194,18 @@ export const contentSearchDocument = ( // value that matches every locale, and what every document written before // Stage 5D already carries. A localized one is indexed once per language. ...(locale === undefined || locale === "" ? {} : { languageCode: locale }), - // Deliberately absent: `containerType` / `containerId` (there is no - // `containerType` query filter to qualify them with), and `metadata` - // (nothing reads it). + // Deliberately absent: `metadata` (nothing reads it). + ...(context + ? { + containerType: context.containerType, + ...(context.containerId === null + ? {} + : { containerId: context.containerId }), + metadata: { + context: { title: normalize(context.title), url: context.url }, + }, + } + : {}), title, updatedAt: toDate(values.updatedAt), ...(url === null ? {} : { url }), diff --git a/packages/vitnode/src/content/server/search-indexer.ts b/packages/vitnode/src/content/server/search-indexer.ts index e245d68238..bf1c718b8a 100644 --- a/packages/vitnode/src/content/server/search-indexer.ts +++ b/packages/vitnode/src/content/server/search-indexer.ts @@ -17,6 +17,7 @@ import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition, ContentFieldMap } from "../types"; import type { ContentAdvancedStore } from "./advanced-store"; import type { ContentModel } from "./model"; +import type { ContentSearchContext } from "./search-document"; import { ContentEngineError } from "../errors"; import { contentIdKey, parseContentId } from "../ids"; @@ -33,6 +34,10 @@ import { contentSearchDocument, contentTranslationSearchDocument, } from "./search-document"; +import { + contentSearchContextKey, + loadContentSearchContexts, +} from "./search-related"; /** System columns the mapper reads, on top of the configured search fields. */ const REQUIRED_COLUMNS = [ @@ -174,12 +179,31 @@ export interface ContentSearchIndexer extends SearchIndexer { ) => Promise; } +export interface ContentSearchRelationPage { + documents: SearchDocument[]; + lastId: ContentId | null; + unindexed: ContentId[]; +} + +export interface ContentSearchRelationIndexer extends ContentSearchIndexer { + loadByRelation: ( + c: Context, + args: { + after?: ContentId; + context: ContentSearchContext; + field: string; + limit: number; + value: ContentId; + }, + ) => Promise; +} + export const createContentSearchIndexer = < TDefinition extends AnyContentTypeDefinition, >( model: ContentModel, { pluginId }: { pluginId: string }, -): ContentSearchIndexer => { +): ContentSearchRelationIndexer => { const { definition } = model; // Widened the same way `createContentPublicService` takes it: the query // builders are written against the erased table, not this content type's. @@ -198,6 +222,54 @@ export const createContentSearchIndexer = < const cursors = new WeakMap(); + const buildDocuments = async ( + c: Context, + rows: readonly Record[], + { + contexts, + fixedContext, + }: { + contexts?: Map; + fixedContext?: ContentSearchContext; + }, + ): Promise<{ document: null | SearchDocument; id: ContentId | null }[]> => { + // One batch for the whole page, and only the collections the search + // configuration names - never one query per document. + const collections = await loadSearchCollections({ + advanced, + c, + definition, + itemIds: rows.map(row => row.id), + wanted: sources.collections, + }); + + return rows.map(values => { + const contextKey = contentSearchContextKey(definition, values); + + return { + document: contentSearchDocument( + definition, + { + ...values, + // Folded back into the nested logical shape the document builder + // reads: it is handed `seo.description`, and a flat `seoDescription` + // column would resolve to nothing. + ...contentColumnsToValues(sources.sharedGroups, values), + ...collections.get(rowKey(values)), + }, + { + context: + fixedContext ?? + (contextKey === null ? null : contexts?.get(contextKey)), + pluginId, + routing: contentLocaleRouting(c), + }, + ), + id: parseContentId(definition.idStrategy, values.id), + }; + }); + }; + return { itemType: definition.id, @@ -229,35 +301,47 @@ export const createContentSearchIndexer = < : null; if (lastId !== null) cursors.set(c, lastId); - // One batch for the whole page, and only the collections the search - // configuration names - never one query per document. - const collections = await loadSearchCollections({ - advanced, - c, - definition, - itemIds: rows.map(row => (row as Record).id), - wanted: sources.collections, + const built = await buildDocuments(c, rows, { + contexts: await loadContentSearchContexts(c, definition, rows), }); - const documents = rows.flatMap(row => { - const values = row as Record; - const document = contentSearchDocument( - definition, - { - ...values, - // Folded back into the nested logical shape the document builder - // reads: it is handed `seo.description`, and a flat `seoDescription` - // column would resolve to nothing. - ...contentColumnsToValues(sources.sharedGroups, values), - ...collections.get(rowKey(values)), - }, - { pluginId, routing: contentLocaleRouting(c) }, - ); + return { + documents: built.flatMap(({ document }) => + document ? [document] : [], + ), + itemsRead: rows.length, + }; + }, - return document ? [document] : []; - }) satisfies SearchDocument[]; + loadByRelation: async (c, { after, context, field, limit, value }) => { + const column = columns[field]; + if (!column) return { documents: [], lastId: null, unindexed: [] }; + + const rows = await c + .get("db") + .select(selection) + .from(table) + .where( + and( + eq(column, value), + after === undefined ? undefined : gt(primaryCursor, after), + ), + ) + .orderBy(asc(primaryCursor)) + .limit(limit); + + const built = await buildDocuments(c, rows, { fixedContext: context }); + const last = rows.at(-1); - return { documents, itemsRead: rows.length }; + return { + documents: built.flatMap(({ document }) => + document ? [document] : [], + ), + lastId: last ? parseContentId(definition.idStrategy, last.id) : null, + unindexed: built.flatMap(({ document, id }) => + document || id === null ? [] : [id], + ), + }; }, }; }; diff --git a/packages/vitnode/src/content/server/search-related.ts b/packages/vitnode/src/content/server/search-related.ts new file mode 100644 index 0000000000..bca5fa41a1 --- /dev/null +++ b/packages/vitnode/src/content/server/search-related.ts @@ -0,0 +1,191 @@ +import type { PgColumn } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, eq, inArray } from "drizzle-orm"; + +import type { ContentLocaleRouting } from "../public-url"; +import type { ContentSearchContextSource } from "../search"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentSearchContext } from "./search-document"; + +import { core_content_containers } from "../../database/content-containers"; +import { contentIdKey, parseContentId } from "../ids"; +import { + contentSearchContextSource, + contentSearchTemplateUrl, + contentSearchUrl, +} from "../search"; +import { contentLocaleRouting } from "./locale-routing"; +import { findContentModel } from "./model"; +import { isContentRowPublic } from "./search-document"; + +const RELATED_PUBLICATION_COLUMNS = ["status", "publishedAt", "hiddenAt"]; + +const contextIdOf = ( + source: ContentSearchContextSource, + value: unknown, +): null | number | string => { + if (source.kind === "container") { + return typeof value === "number" && Number.isSafeInteger(value) + ? value + : null; + } + + return parseContentId(source.target.idStrategy, value); +}; + +export const contentSearchContextKey = ( + definition: AnyContentTypeDefinition, + row: object, +): null | string => { + const source = contentSearchContextSource(definition); + if (!source) return null; + + const id = contextIdOf( + source, + (row as Record)[source.field], + ); + + return id === null ? null : contentIdKey(id); +}; + +export const toRelatedSearchContext = ( + target: AnyContentTypeDefinition, + row: object, + routing: ContentLocaleRouting, +): ContentSearchContext => { + const values = row as Record; + const title = values[target.search.titleField]; + const slug = values[target.publicApi.slugField]; + + return { + containerId: + typeof values.id === "number" && Number.isSafeInteger(values.id) + ? values.id + : null, + containerType: target.id, + isPublic: isContentRowPublic(values), + title: typeof title === "string" ? title : "", + url: + typeof slug === "string" + ? contentSearchUrl({ definition: target, routing, slug }) + : null, + }; +}; + +export const toContainerSearchContext = ( + source: Extract, + container: { id: number; name: string; slug: string }, + routing: ContentLocaleRouting, +): ContentSearchContext => ({ + containerId: container.id, + containerType: source.containerType, + isPublic: true, + title: container.name, + url: contentSearchTemplateUrl({ + pathTemplate: source.pathTemplate, + routing, + slug: container.slug, + }), +}); + +const loadRelatedContexts = async ( + c: Context, + source: Extract, + ids: readonly (number | string)[], +): Promise<[string, ContentSearchContext][]> => { + const { target } = source; + if (!target.search.enabled) return []; + + const targetModel = findContentModel( + c.get("core")?.contentModels ?? [], + target.id, + )?.model; + if (!targetModel) return []; + + const columns = targetModel.columns as Record; + const idColumn = columns.id; + const titleColumn = columns[target.search.titleField]; + const slugColumn = columns[target.publicApi.slugField]; + if (!idColumn || !titleColumn) return []; + + const selection: Record = { + id: idColumn, + [target.search.titleField]: titleColumn, + ...(slugColumn ? { [target.publicApi.slugField]: slugColumn } : {}), + }; + for (const name of 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 routing = contentLocaleRouting(c); + + return rows.flatMap(row => { + const id = parseContentId(target.idStrategy, row.id); + + return id === null + ? [] + : [[contentIdKey(id), toRelatedSearchContext(target, row, routing)]]; + }); +}; + +const loadContainerContexts = async ( + c: Context, + source: Extract, + ids: readonly (number | string)[], +): Promise<[string, ContentSearchContext][]> => { + const numeric = ids.filter((id): id is number => typeof id === "number"); + if (numeric.length === 0) return []; + + const rows = await c + .get("db") + .select({ + id: core_content_containers.id, + name: core_content_containers.name, + slug: core_content_containers.slug, + }) + .from(core_content_containers) + .where( + and( + eq(core_content_containers.definitionId, source.containerType), + inArray(core_content_containers.id, numeric), + ), + ); + const routing = contentLocaleRouting(c); + + return rows.map(row => [ + contentIdKey(row.id), + toContainerSearchContext(source, row, routing), + ]); +}; + +export const loadContentSearchContexts = async ( + c: Context, + definition: AnyContentTypeDefinition, + rows: readonly object[], +): Promise> => { + const source = contentSearchContextSource(definition); + if (!source) return new Map(); + + const ids = new Map(); + for (const row of rows) { + const id = contextIdOf( + source, + (row as Record)[source.field], + ); + if (id !== null) ids.set(contentIdKey(id), id); + } + if (ids.size === 0) return new Map(); + + return new Map( + source.kind === "relation" + ? await loadRelatedContexts(c, source, [...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 f219128fe2..9d8d5e3f27 100644 --- a/packages/vitnode/src/content/server/search-sync.ts +++ b/packages/vitnode/src/content/server/search-sync.ts @@ -6,11 +6,14 @@ import { eq } from "drizzle-orm"; import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { AnyContentModel } from "./model"; +import type { RegisteredContentModel } from "./model"; +import type { ContentSearchContext } from "./search-document"; import { parseContentId } from "../ids"; import { partitionContentFields } from "../localization"; import { contentColumnsToValues, contentStorageColumns } from "../paths"; import { + contentSearchContextSource, contentSearchDocumentId, contentSearchIndexedCollections, contentSearchIndexedPaths, @@ -22,6 +25,13 @@ import { contentSearchDocument, contentTranslationSearchDocument, } from "./search-document"; +import { createContentSearchIndexer } from "./search-indexer"; +import { + contentSearchContextKey, + loadContentSearchContexts, + toContainerSearchContext, + toRelatedSearchContext, +} from "./search-related"; /** Which mutation just returned. Not an event name: nothing is emitted here. */ export type ContentSearchOperation = @@ -110,12 +120,23 @@ export const syncContentSearch = async ( // A row the mapper will not build - a title that is only whitespace, say - has // its document removed rather than left holding whatever text it was indexed // with last time. + const contextKey = contentSearchContextKey(definition, values); + const context = + decided === "upsert" && contextKey !== null + ? (await loadContentSearchContexts(c, definition, [values])).get( + contextKey, + ) + : undefined; const document = decided === "upsert" ? contentSearchDocument( definition, { ...input.row, ...input.advanced }, - { pluginId: input.pluginId, routing: contentLocaleRouting(c) }, + { + context, + pluginId: input.pluginId, + routing: contentLocaleRouting(c), + }, ) : null; const action = decided === "upsert" && !document ? "delete" : decided; @@ -127,6 +148,12 @@ export const syncContentSearch = async ( await c.get("search").delete(definition.id, itemId); } + await syncContentSearchDependents(c, definition, { + documentId, + input, + itemId, + }); + return { action, documentId }; } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); @@ -162,6 +189,157 @@ export const syncContentSearch = async ( } }; +const CONTENT_SEARCH_DEPENDENT_PAGE_SIZE = 200; + +const dependentSyncOf = ( + definition: AnyContentTypeDefinition, + { changed, changedFields, operation }: ContentSearchSyncInput, +): "delete" | "skip" | "upsert" => { + if (operation === "delete") return "delete"; + if (operation === "create") return "skip"; + if (isReachabilityOperation(operation)) { + return changed === true ? "upsert" : "skip"; + } + + const linked = [definition.search.titleField, definition.publicApi.slugField]; + + return (changedFields ?? []).some(name => linked.includes(name)) + ? "upsert" + : "skip"; +}; + +const reindexContentSearchDependent = async ( + c: Context, + { model, pluginId }: RegisteredContentModel, + { + context, + field, + value, + }: { context: ContentSearchContext; field: string; value: ContentId }, +): Promise => { + const dependent = contentDefinitionOf(model); + const indexer = createContentSearchIndexer(model, { pluginId }); + let after: ContentId | undefined; + + for (;;) { + const page = await indexer.loadByRelation(c, { + after, + context, + field, + limit: CONTENT_SEARCH_DEPENDENT_PAGE_SIZE, + value, + }); + + if (page.documents.length > 0) { + await c.get("search").bulkIndex(page.documents); + } + for (const id of page.unindexed) { + await c.get("search").delete(dependent.id, id); + } + + const read = page.documents.length + page.unindexed.length; + if (page.lastId === null || read < CONTENT_SEARCH_DEPENDENT_PAGE_SIZE) { + return; + } + after = page.lastId; + } +}; + +const syncContentSearchDependents = async ( + c: Context, + definition: AnyContentTypeDefinition, + { + documentId, + input, + itemId, + }: { documentId: string; input: ContentSearchSyncInput; itemId: ContentId }, +): Promise => { + const sync = dependentSyncOf(definition, input); + if (sync === "skip") return; + + const dependents = (c.get("core")?.contentModels ?? []).flatMap(entry => { + const source = contentSearchContextSource(contentDefinitionOf(entry.model)); + + return source?.kind === "relation" && source.target.id === definition.id + ? [{ entry, field: source.field }] + : []; + }); + if (dependents.length === 0) return; + + await write(c, definition, { + action: sync, + documentId, + input, + itemId, + run: async () => { + for (const { entry, field } of dependents) { + const dependent = contentDefinitionOf(entry.model); + + if (sync === "delete") { + if (typeof itemId === "number") { + await c.get("search").deleteByContainer({ + containerId: itemId, + containerType: definition.id, + itemType: dependent.id, + }); + } + continue; + } + + await reindexContentSearchDependent(c, entry, { + context: toRelatedSearchContext( + definition, + input.row, + contentLocaleRouting(c), + ), + field, + value: itemId, + }); + } + }, + }); +}; + +export const syncContentSearchContainerDependents = async ( + c: Context, + { + container, + containerType, + }: { + container: { id: number; name: string; slug: string }; + containerType: string; + }, +): Promise => { + for (const entry of c.get("core")?.contentModels ?? []) { + const definition = contentDefinitionOf(entry.model); + const source = contentSearchContextSource(definition); + if ( + source?.kind !== "container" || + source.containerType !== containerType + ) { + continue; + } + + await write(c, definition, { + action: "upsert", + documentId: `${containerType}:${container.id}`, + input: { operation: "update", pluginId: entry.pluginId }, + itemId: container.id, + run: async () => { + await reindexContentSearchDependent(c, entry, { + context: toContainerSearchContext( + source, + container, + contentLocaleRouting(c), + ), + field: source.field, + value: container.id, + }); + }, + }); + } +}; + export interface ContentLocalizedSearchSyncInput { /** * The record's shared advanced collections, when the content type indexes one. diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index 80031e3e6a..02109b6d93 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -49,6 +49,7 @@ import type { } from "./visibility"; import { withPagination } from "../../api/lib/with-pagination"; +import { cancelContentSubmissions } from "../approvals/server/submissions"; import { CONTENT_DEFAULT_PAGE_SIZE, CONTENT_EDITORIAL_FIELDS, @@ -83,12 +84,14 @@ import { toInsertColumns, } from "./query"; import { + assertNoContentSelfReference, LABEL_PREFIX, resolveCollectionPickerTargets, resolveReferenceTargets, toLabel, } from "./references"; import { createSlugNormalizer } from "./slugs"; +import { assertContentThreadPlacement } from "./thread"; import { isInVisibilityState, visibilityChange, @@ -802,6 +805,14 @@ export const createContentService = < // content type with no file fields. await assertContentFileReferences(c, definition, parsed, tx); await resolveContentContainerAssignment(tx, definition, parsed); + await assertContentThreadPlacement( + tx, + { columns, definition, table }, + { + id: null, + values: parsed, + }, + ); const [row] = await tx .insert(table) @@ -827,6 +838,7 @@ export const createContentService = < .delete(table) .where(eq(primaryCursor, id)) .returning(ownSelection()); + if (row) await cancelContentSubmissions(db(options), definition, id); return row ? toRow(row) : null; }, @@ -1123,6 +1135,15 @@ export const createContentService = < }); if (changedPaths.length > 0) { + assertNoContentSelfReference(definition, id, patch); + await assertContentThreadPlacement( + tx, + { columns, definition, table }, + { + id, + values: patch, + }, + ); await resolveContentContainerAssignment(tx, definition, patch); } diff --git a/packages/vitnode/src/content/server/slugs.ts b/packages/vitnode/src/content/server/slugs.ts index 2718afc88e..46a092286d 100644 --- a/packages/vitnode/src/content/server/slugs.ts +++ b/packages/vitnode/src/content/server/slugs.ts @@ -1,10 +1,28 @@ +import { randomInt } from "node:crypto"; + import type { ContentFieldMap } from "../types"; -import { CONTENT_SLUG_DEFAULT_LENGTH } from "../const"; +import { + CONTENT_GENERATED_SLUG_LENGTH, + CONTENT_SLUG_DEFAULT_LENGTH, +} from "../const"; import { ContentInputError } from "../errors"; import { slugify } from "../slug"; +const GENERATED_SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; + +/** A random, URL-safe, lowercase token - the slug of a record with no title. */ +export const generateContentSlug = ( + maxLength: number = CONTENT_SLUG_DEFAULT_LENGTH, +): string => + Array.from( + { length: Math.min(CONTENT_GENERATED_SLUG_LENGTH, maxLength) }, + () => GENERATED_SLUG_ALPHABET[randomInt(GENERATED_SLUG_ALPHABET.length)], + ).join(""); + interface SlugFieldConfig { + /** Minted on create when the payload omits it. */ + generated: boolean; maxLength: number; name: string; /** Field the value is derived from when a create payload omits the slug. */ @@ -18,6 +36,7 @@ const slugFieldsOf = (fields: ContentFieldMap): SlugFieldConfig[] => { if (fieldValue.kind !== "slug") continue; slugFields.push({ + generated: fieldValue.generated, maxLength: fieldValue.maxLength ?? CONTENT_SLUG_DEFAULT_LENGTH, name, source: fieldValue.source, @@ -72,6 +91,11 @@ export const createSlugNormalizer = ( continue; } + if (slugField.generated) { + next[slugField.name] = generateContentSlug(slugField.maxLength); + continue; + } + // `assertSlugSources` guarantees a source exists whenever the create // schema lets the value be omitted, so this is the derived branch. const source = slugField.source ?? ""; diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index 390336fc57..5287144228 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -132,6 +132,7 @@ const resolveReference = ( fields: ContentFieldMap, references: Record, builtStrategy?: ContentIdStrategy, + selfReference?: { tableName: string; thunk: ColumnReferenceThunk }, ): ColumnReferenceThunk | undefined => { const fieldValue = fields[name]; @@ -164,12 +165,32 @@ const resolveReference = ( } if (fieldValue.kind !== "relation") return undefined; + if (fieldValue.self) { + if (references[name] !== undefined) { + throw new ContentEngineError( + `Self-relation "${name}" has an entry in \`references\`. Remove it - the engine resolves a self-relation from the table it is building.`, + { contentTypeId }, + ); + } + if (!selfReference) { + throw new ContentEngineError( + `Self-relation "${name}" was built without its own table to point at. This is an internal error.`, + { contentTypeId }, + ); + } + + return checkedReference( + contentTypeId, + name, + () => selfReference.tableName, + selfReference.thunk, + ); + } + const thunk = references[name]; if (!thunk) { throw new ContentEngineError( - fieldValue.self - ? `Self-relation "${name}" should not have an entry in \`references\` - the engine resolves it from the table it is building. This is an internal error.` - : `Relation field "${name}" has no entry in \`references\`. Add \`${name}: () => .id\`.`, + `Relation field "${name}" has no entry in \`references\`. Add \`${name}: () => .id\`.`, { contentTypeId }, ); } @@ -213,6 +234,14 @@ export const createContentTable = < partitionContentFields(definition.fields).sharedFields, ); const referenceThunks = references as Record; + // A to-one self-relation's foreign key points at this very table, which does + // not exist until `pgTable` returns. Drizzle reads the thunk lazily, so it + // resolves against the finished table. + const built: { columns?: Record } = {}; + const selfReference = { + tableName, + thunk: (() => built.columns?.id) as ColumnReferenceThunk, + }; const columns: Record = { ...buildSystemColumns(definition.idStrategy), @@ -239,6 +268,7 @@ export const createContentTable = < fields, referenceThunks, referenceStrategy, + selfReference, ), referenceStrategy, }); @@ -274,11 +304,14 @@ export const createContentTable = < // in a loop, so the descriptor-derived `ContentTableFor` is re-attached here. // It is built from Drizzle's own `BuildColumns`, so `$inferSelect` and // `$inferInsert` stay accurate - see `table.test-d.ts`. - return camelCase.table.withRLS( + const table = camelCase.table.withRLS( tableName, () => columns, - table => buildIndexes(table as unknown as Record), - ) as unknown as ContentTableFor; + columnMap => buildIndexes(columnMap as unknown as Record), + ); + built.columns = table as unknown as Record; + + return table as unknown as ContentTableFor; }; export const assertContentReferences = (table: PgTable): void => { diff --git a/packages/vitnode/src/content/server/thread-routes.test.ts b/packages/vitnode/src/content/server/thread-routes.test.ts new file mode 100644 index 0000000000..f888deb181 --- /dev/null +++ b/packages/vitnode/src/content/server/thread-routes.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { + BOARD, + createPolicyWorld, + POLICY_LIVE, + policyReplyRow, + policyTopicRow, +} from "@/tests/content-policies"; + +import type { ContentThreadPage } from "./thread"; + +const TOPIC = { DRAFT: 12, HIDDEN: 13, LIVE: 10 } as const; + +const draft = { publishedAt: null, status: "draft" }; + +/** + * Topic 10, oldest first: + * + * 100 ─ 101 ─ 102 + * 103 (draft) ─ 104 + * 105 (hidden, no answers) + * 106 ─ 107 (draft) + */ +const { send } = await createPolicyWorld({ + replies: [ + policyReplyRow(100, TOPIC.LIVE), + policyReplyRow(101, TOPIC.LIVE, { parent: 100 }), + policyReplyRow(102, TOPIC.LIVE, { parent: 101 }), + policyReplyRow(103, TOPIC.LIVE, draft), + policyReplyRow(104, TOPIC.LIVE, { parent: 103 }), + policyReplyRow(105, TOPIC.LIVE, { hiddenAt: POLICY_LIVE }), + policyReplyRow(106, TOPIC.LIVE), + policyReplyRow(107, TOPIC.LIVE, { parent: 106, ...draft }), + ], + topics: [ + policyTopicRow(TOPIC.LIVE, BOARD.OPEN), + policyTopicRow(TOPIC.DRAFT, BOARD.OPEN, draft), + policyTopicRow(TOPIC.HIDDEN, BOARD.OPEN, { hiddenAt: POLICY_LIVE }), + ], +}); + +type Page = ContentThreadPage<{ body: string; id: number }>; + +const read = async (query: string): Promise => { + const response = await send("GET", `/replies/thread?${query}`); + expect(response.status).toBe(200); + + return (await response.json()) as Page; +}; + +const shape = (page: Page) => + page.items.map(({ depth, id, parentId, removed }) => ({ + depth, + id, + parentId, + removed, + })); + +describe("GET /replies/thread", () => { + it.each([ + ["a draft", String(TOPIC.DRAFT)], + ["a hidden", String(TOPIC.HIDDEN)], + ["a missing", "999"], + ["a malformed", "abc"], + ])("answers 404 for %s root", async (_label, root) => { + expect((await send("GET", `/replies/thread?root=${root}`)).status).toBe( + 404, + ); + }); + + it("flattens the thread depth first and keeps a removed reply that has public answers", async () => { + const page = await read(`root=${TOPIC.LIVE}`); + + expect(shape(page)).toEqual([ + { depth: 1, id: 100, parentId: null, removed: false }, + { depth: 2, id: 101, parentId: 100, removed: false }, + { depth: 3, id: 102, parentId: 101, removed: false }, + { depth: 1, id: 103, parentId: null, removed: true }, + { depth: 2, id: 104, parentId: 103, removed: false }, + { depth: 1, id: 106, parentId: null, removed: false }, + ]); + expect(page.items.find(item => item.id === 103)?.record).toBeNull(); + expect(page.items.find(item => item.id === 104)?.record).toMatchObject({ + body: "Reply 104", + }); + }); + + it("counts public replies only", async () => { + const page = await read(`root=${TOPIC.LIVE}`); + + expect(page.replyCount).toBe(5); + expect(page.pageInfo.totalCount).toBe(2); + }); + + it("pages top-level replies with the after cursor", async () => { + const first = await read(`root=${TOPIC.LIVE}&first=2`); + expect(first.pageInfo.hasNextPage).toBe(true); + expect(shape(first).map(item => item.id)).toEqual([ + 100, 101, 102, 103, 104, + ]); + + const second = await read( + `root=${TOPIC.LIVE}&first=2&after=${first.pageInfo.endCursor}`, + ); + + expect(second.pageInfo.hasNextPage).toBe(false); + expect(shape(second)).toEqual([ + { depth: 1, id: 106, parentId: null, removed: false }, + ]); + }); +}); diff --git a/packages/vitnode/src/content/server/thread.ts b/packages/vitnode/src/content/server/thread.ts new file mode 100644 index 0000000000..652dc8e53c --- /dev/null +++ b/packages/vitnode/src/content/server/thread.ts @@ -0,0 +1,407 @@ +import type { + PgColumn, + PgTableWithColumns, + TableConfig, +} from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, asc, count, eq, gt, inArray, isNull, or } from "drizzle-orm"; + +import type { ContentId } from "../ids"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentModel } from "./model"; +import type { ContentDatabase } from "./service"; + +import { ContentInputError } from "../errors"; +import { publicationColumns, publishedCondition } from "./publication"; +import { isContentBaseRowPublic } from "./visibility"; + +export class ContentThreadPlacementError extends ContentInputError { + constructor({ + contentTypeId, + field, + message, + }: { + contentTypeId: string; + field: string; + message: string; + }) { + super(message, { contentTypeId }); + this.name = "ContentThreadPlacementError"; + this.detail = message; + this.field = field; + } + + readonly detail: string; + readonly field: string; +} + +interface ThreadTable { + columns: Record; + definition: AnyContentTypeDefinition; + table: PgTableWithColumns; +} + +const keyOf = (value: unknown): null | string => + typeof value === "number" || + typeof value === "string" || + typeof value === "bigint" + ? String(value) + : null; + +const same = (a: unknown, b: unknown): boolean => + keyOf(a) !== null && keyOf(a) === keyOf(b); + +/** + * Keeps a thread a tree: the parent answers in the same root, the record never + * ends up below itself, and nothing sits deeper than `thread.maxDepth`. Runs on + * every create and on any update that touches the parent or the root. + */ +export const assertContentThreadPlacement = async ( + tx: ContentDatabase, + { columns, definition, table }: ThreadTable, + { id, values }: { id: ContentId | null; values: Record }, +): Promise => { + const { thread } = definition; + if (!thread.enabled) return; + + const { maxDepth, parentField, rootField } = thread; + const touchesParent = Object.hasOwn(values, parentField); + const touchesRoot = Object.hasOwn(values, rootField); + if (id !== null && !touchesParent && !touchesRoot) return; + + const refuse = (field: string, message: string): never => { + throw new ContentThreadPlacementError({ + contentTypeId: definition.id, + field, + message, + }); + }; + + const parentColumn = columns[parentField]; + const rootColumn = columns[rootField]; + const select = { id: columns.id, parent: parentColumn, root: rootColumn }; + + const [current] = + id === null + ? [undefined] + : await tx.select(select).from(table).where(eq(columns.id, id)).limit(1); + + const parentId = touchesParent ? values[parentField] : current?.parent; + const rootId = touchesRoot ? values[rootField] : current?.root; + + if (id !== null && touchesRoot && !same(rootId, current?.root)) { + const [child] = await tx + .select({ id: columns.id }) + .from(table) + .where(eq(parentColumn, id)) + .limit(1); + if (child) { + refuse( + rootField, + "This reply has answers. Move the whole conversation instead of one reply.", + ); + } + } + + if (parentId === null || parentId === undefined) return; + + let cursor: unknown = parentId; + let depth = 1; + const seen = new Set(); + while (cursor !== null && cursor !== undefined) { + const key = keyOf(cursor); + if (key === null || seen.has(key)) { + refuse(parentField, "The reply chain loops back on itself."); + } + seen.add(key ?? ""); + + const [row] = await tx + .select(select) + .from(table) + .where(eq(columns.id, cursor as ContentId)) + .limit(1); + if (!row) + refuse(parentField, "The reply you are answering does not exist."); + if (depth === 1 && !same(row?.root, rootId)) { + refuse( + parentField, + "The reply you are answering belongs to another conversation.", + ); + } + if (id !== null && same(row?.id, id)) { + refuse( + parentField, + "A reply cannot answer itself or one of its answers.", + ); + } + + depth += 1; + if (depth > maxDepth) { + refuse( + parentField, + `Replies nest at most ${maxDepth} levels deep. Answer a reply higher up.`, + ); + } + cursor = row?.parent; + } +}; + +export interface ContentThreadNode { + /** How deep the reply sits: 1 for a direct answer to the root. */ + depth: number; + id: ContentId; + parentId: ContentId | null; + /** The public record, or `null` for a removed reply kept so its answers stay in place. */ + record: null | TRecord; + removed: boolean; +} + +export interface ContentThreadPage { + items: ContentThreadNode[]; + pageInfo: { + endCursor: null | string; + hasNextPage: boolean; + /** Public top-level replies in the whole thread. */ + totalCount: number; + }; + /** Every public reply in the thread, at any depth. */ + replyCount: number; + /** Set when a page held more nested replies than one response carries. */ + truncated: boolean; +} + +export const CONTENT_THREAD_MAX_NODES = 500; + +const encodeCursor = (createdAt: Date, id: ContentId): string => + Buffer.from(JSON.stringify([createdAt.toISOString(), id])).toString( + "base64url", + ); + +const decodeCursor = ( + cursor: string | undefined, +): null | { createdAt: Date; id: ContentId } => { + if (!cursor) return null; + try { + const [createdAt, id] = JSON.parse( + Buffer.from(cursor, "base64url").toString("utf8"), + ) as [unknown, unknown]; + const date = typeof createdAt === "string" ? new Date(createdAt) : null; + if (!date || Number.isNaN(date.getTime())) return null; + if (typeof id !== "number" && typeof id !== "string") return null; + + return { createdAt: date, id }; + } catch { + return null; + } +}; + +interface NodeRow extends Record { + createdAt: Date; + hiddenAt?: Date | null; + id: ContentId; + parent: ContentId | null; + publishedAt: Date | null; + status: string; +} + +/** + * Pages a thread oldest first: a page is a run of top-level replies plus every + * answer below them, flattened depth first. A reply that is not public is left + * out - unless a public answer sits below it, in which case it is returned as + * `removed` with no values, so the tree keeps its shape. + */ +export const readContentThread = async < + TDefinition extends AnyContentTypeDefinition, + TRecord, +>( + c: Context, + model: ContentModel, + { + first, + after, + project, + root, + }: { + after?: string; + first: number; + /** Public rows for these ids, in any order. */ + project: (ids: ContentId[]) => Promise>; + root: ContentId; + }, +): Promise> => { + const { definition } = model; + const { maxDepth, parentField, rootField } = definition.thread; + const columns = model.columns as Record; + const table: PgTableWithColumns = model.table; + const parentColumn = columns[parentField]; + const rootColumn = columns[rootField]; + const db = c.get("db"); + const published = publicationColumns(definition, columns); + const publicCondition = publishedCondition(published); + + const nodeSelection = { + createdAt: columns.createdAt, + id: columns.id, + parent: parentColumn, + publishedAt: columns.publishedAt, + status: columns.status, + ...(columns.hiddenAt ? { hiddenAt: columns.hiddenAt } : {}), + }; + + const topLevel = and(eq(rootColumn, root), isNull(parentColumn)); + const after_ = (position: null | { createdAt: Date; id: ContentId }) => + position + ? or( + gt(columns.createdAt, position.createdAt), + and( + eq(columns.createdAt, position.createdAt), + gt(columns.id, position.id), + ), + ) + : undefined; + + /** + * A top-level reply that is not public earns a place on the page only when + * something answers it - otherwise it would be pruned and leave a page short, + * or empty with `hasNextPage` still set. Skipped ones are read past in a few + * bounded rounds. + */ + const readTops = async (): Promise => { + const collected: NodeRow[] = []; + let position = decodeCursor(after); + + for (let round = 0; round < 5 && collected.length <= first; round++) { + const batch = (await db + .select(nodeSelection) + .from(table) + .where(and(topLevel, after_(position))) + .orderBy(asc(columns.createdAt), asc(columns.id)) + .limit(first + 1)) as NodeRow[]; + if (batch.length === 0) break; + + const hidden = batch + .filter(row => !isContentBaseRowPublic(row)) + .map(row => row.id); + const answered = new Set( + hidden.length === 0 + ? [] + : ( + await db + .select({ parent: parentColumn }) + .from(table) + .where(inArray(parentColumn, hidden)) + ).map(row => String(row.parent)), + ); + collected.push( + ...batch.filter( + row => isContentBaseRowPublic(row) || answered.has(String(row.id)), + ), + ); + + const last = batch.at(-1); + if (!last || batch.length <= first) break; + position = { createdAt: last.createdAt, id: last.id }; + } + + return collected; + }; + + const [tops, [topTotal], [replyTotal]] = await Promise.all([ + readTops(), + db + .select({ value: count() }) + .from(table) + .where(and(topLevel, publicCondition)), + db + .select({ value: count() }) + .from(table) + .where(and(eq(rootColumn, root), publicCondition)), + ]); + + const hasNextPage = tops.length > first; + const page = tops.slice(0, first); + + const nodes = new Map(); + const children = new Map(); + for (const row of page) nodes.set(String(row.id), row); + + let frontier = page.map(row => row.id); + let truncated = false; + for (let level = 2; level <= maxDepth && frontier.length > 0; level++) { + if (nodes.size >= CONTENT_THREAD_MAX_NODES) { + truncated = true; + break; + } + const rows = (await db + .select(nodeSelection) + .from(table) + .where(and(eq(rootColumn, root), inArray(parentColumn, frontier))) + .orderBy(asc(columns.createdAt), asc(columns.id)) + .limit(CONTENT_THREAD_MAX_NODES - nodes.size + 1)) as NodeRow[]; + if (rows.length > CONTENT_THREAD_MAX_NODES - nodes.size) { + truncated = true; + rows.length = CONTENT_THREAD_MAX_NODES - nodes.size; + } + + for (const row of rows) { + nodes.set(String(row.id), row); + const key = String(row.parent); + children.set(key, [...(children.get(key) ?? []), row]); + } + frontier = rows.map(row => row.id); + } + + const kept = new Set(); + const keep = (row: NodeRow): boolean => { + let any = false; + for (const child of children.get(String(row.id)) ?? []) { + if (keep(child)) any = true; + } + if (any || isContentBaseRowPublic(row)) { + kept.add(String(row.id)); + + return true; + } + + return false; + }; + for (const row of page) keep(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), + ); + + 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; + items.push({ + depth, + id: row.id, + parentId: row.parent, + record, + removed: record === null, + }); + for (const child of children.get(key) ?? []) walk(child, depth + 1); + }; + for (const row of page) walk(row, 1); + + const last = page.at(-1); + + return { + items, + pageInfo: { + endCursor: last ? encodeCursor(last.createdAt, last.id) : null, + hasNextPage, + totalCount: topTotal?.value ?? 0, + }, + replyCount: replyTotal?.value ?? 0, + truncated, + }; +}; diff --git a/packages/vitnode/src/content/server/write-effects.ts b/packages/vitnode/src/content/server/write-effects.ts new file mode 100644 index 0000000000..b546e88cab --- /dev/null +++ b/packages/vitnode/src/content/server/write-effects.ts @@ -0,0 +1,104 @@ +import type { Context } from "hono"; + +import type { ContentId } from "../ids"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentEditorialOutcome } from "./editorial-service"; +import type { ContentModel } from "./model"; + +import { requireContentId } from "../ids"; +import { contentEditorialEffects } from "./editorial-effects"; +import { emitContentEvent } from "./emit"; +import { contentSearchAdvancedValues, syncContentSearch } from "./search-sync"; + +/** + * Events and search indexing after a committed write on a content type without + * an editorial workflow. Editorial writes go through `contentEditorialEffects`. + */ +export const contentPlainWriteEffects = async < + TDefinition extends AnyContentTypeDefinition, +>( + c: Context, + model: ContentModel, + { + changedFields = [], + operation, + pluginId, + published = false, + row, + }: { + changedFields?: string[]; + operation: "create" | "publish" | "update"; + pluginId: string; + published?: boolean; + row: Record; + }, +): Promise => { + const { definition } = model; + const id = row.id as ContentId; + + if (operation === "create") { + await emitContentEvent( + c, + definition, + "created", + { contentId: id }, + { pluginId }, + ); + } else if (operation === "update" && changedFields.length > 0) { + await emitContentEvent( + c, + definition, + "updated", + { changedFields, contentId: id }, + { pluginId }, + ); + } + + if ( + (operation === "publish" || (operation === "create" && published)) && + row.publishedAt instanceof Date + ) { + await emitContentEvent( + c, + definition, + "published", + { contentId: id, publishedAt: row.publishedAt }, + { pluginId }, + ); + } + + await syncContentSearch(c, definition, { + advanced: await contentSearchAdvancedValues( + c, + model, + requireContentId(definition.idStrategy, id, definition.id), + ), + ...(operation === "update" ? { changedFields } : {}), + ...(operation === "publish" ? { changed: true } : {}), + operation, + pluginId, + row, + }); +}; + +export const contentEditorialWriteEffects = async < + TDefinition extends AnyContentTypeDefinition, +>( + c: Context, + model: ContentModel, + { + outcomes, + pluginId, + }: { + outcomes: (ContentEditorialOutcome | null)[]; + pluginId: string; + }, +): Promise => { + for (const outcome of outcomes) { + if (!outcome) continue; + await contentEditorialEffects(c, model.definition, outcome, { + model, + pluginId, + }); + } +}; diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 1218b4a07e..b305a9b922 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1,4 +1,5 @@ import type { BlockAllowedSpec, ContentNode } from "../blocks/types"; +import type { ContentApprovalState } from "./approvals/schema"; import type { CONTENT_ADMIN_FORM_MODES, CONTENT_DELIVERY_DESCRIPTION_KINDS, @@ -28,6 +29,7 @@ import type { ContentIdOfStrategy, ContentIdStrategy, } from "./ids"; +import type { ContentPublicPerson } from "./public-person"; import type { RichTextDocument } from "./rich-text/document"; import type { ContentSchemas } from "./schemas"; @@ -106,14 +108,21 @@ export interface ContentTextField< unique?: boolean; } -export type ContentSlugRequired = TSource extends string - ? false - : true; +export type ContentSlugRequired< + TSource, + TGenerated extends boolean = false, +> = TGenerated extends true ? false : TSource extends string ? false : true; export interface ContentSlugField< TSource extends string | undefined = string | undefined, TLocalized extends boolean = boolean, -> extends ContentFieldShared, false> { + TGenerated extends boolean = boolean, +> extends ContentFieldShared, false> { + /** + * The server mints a random URL-safe token on create when no slug is sent - + * for records with no natural title, such as a forum reply. + */ + generated: TGenerated; kind: "slug"; localized: TLocalized; /** `varchar` length and the truncation point. Defaults to 160. */ @@ -258,7 +267,10 @@ export interface ContentRelationField< TOrdered extends boolean = boolean, TSelf extends boolean = boolean, TTargetStrategy extends ContentIdStrategy = ContentIdStrategy, + TContainer extends boolean = boolean, > extends ContentFieldShared { + /** The record lives in the container of the record this points at. */ + container: TContainer; kind: "relation"; min?: number; @@ -995,6 +1007,22 @@ export type ContentReportsInput = | boolean | ContentReportsConfig; +export interface ContentThreadInput { + /** Deepest nesting level a reply may sit at. 1 is a flat list. Defaults to 8. */ + maxDepth?: number; + /** A to-one, nullable `field.relation({ self: true })` - the reply being answered. */ + parent: TField; + /** A required, to-one `field.relation({ target })` - the record the thread belongs to. */ + root: TField; +} + +export interface ResolvedContentThreadConfig { + enabled: boolean; + maxDepth: number; + parentField: string; + rootField: string; +} + export interface ResolvedContentReportsConfig< TEnabled extends boolean = boolean, > { @@ -1063,9 +1091,23 @@ type ExposableFlatFieldKeys = Exclude< * at definition time. Enabling publication on its own never makes anything * public - this block is the only thing that does, by being present. */ +/** + * The `field.user()` fields `publicApi.people` may name: top-level, shared and + * to-one. Each is published as a {@link ContentPublicPerson}. + */ +export type ContentPublicPersonField = string & + { + [K in keyof TFields]: TFields[K] extends { kind: "user"; multiple: false } + ? TFields[K] extends { localized: true } + ? never + : K + : never; + }[keyof TFields]; + export interface ContentPublicApiConfig< TField extends string = string, TPath extends string = string, + TPeople extends string = string, > { defaultOrder?: "asc" | "desc"; /** Defaults to `publishedAt`. Must be orderable. */ @@ -1078,6 +1120,11 @@ export interface ContentPublicApiConfig< orderableFields?: readonly TField[]; /** One lowercase URL segment, e.g. `articles`. Never `admin`. */ path: TPath; + /** + * To-one user fields published as a person summary (name, avatar, role) - + * an explicit opt-in, since `fields` never exposes a user. Defaults to none. + */ + people?: readonly TPeople[]; /** Columns `search` scans. Defaults to none. */ searchableFields?: readonly TField[]; } @@ -1095,6 +1142,7 @@ export interface ResolvedContentPublicApiConfig< TField extends string = string, TEnabled extends boolean = boolean, TPath extends string = string, + TPeople extends string = string, > { defaultOrder: "asc" | "desc"; defaultOrderBy: string; @@ -1103,6 +1151,8 @@ export interface ResolvedContentPublicApiConfig< filterableFields: string[]; orderableFields: string[]; path: TPath; + /** User fields published as a {@link ContentPublicPerson}. */ + people: TPeople[]; searchableFields: string[]; /** The exposed slug field the detail route resolves by. */ slugField: string; @@ -1117,9 +1167,11 @@ export type ContentPublicWritableField = string & ? never : TFields[K] extends { kind: "slug"; source: string } ? never - : TFields[K] extends { kind: ContentPublicWritableKind } - ? K - : never; + : TFields[K] extends { generated: true; kind: "slug" } + ? never + : TFields[K] extends { kind: ContentPublicWritableKind } + ? K + : never; }[keyof TFields]; export type ContentPublicOwnershipField = string & @@ -1262,12 +1314,25 @@ export type ContentSearchTitleField< TPublicField extends string, > = Extract< TPublicField, - ContentNonNullableFieldNamesOfKind< - TFields, - (typeof CONTENT_SEARCH_TITLE_KINDS)[number] - > + | ContentNonNullableFieldNamesOfKind< + TFields, + (typeof CONTENT_SEARCH_TITLE_KINDS)[number] + > + | ContentSearchTitleRelationNames >; +type ContentSearchTitleRelationNames = string & + { + [K in keyof TFields]: TFields[K] extends { + kind: "relation"; + multiple: false; + nullable: false; + self: false; + } + ? K + : never; + }[keyof TFields]; + /** * The generated tables a to-many relation and a repeatable field each get. * @@ -1367,6 +1432,7 @@ export interface ContentSearchConfig< authorField?: TAuthor; /** Concatenated into the indexed body, in order. At least one. */ contentFields: readonly [TText, ...TText[]]; + context?: ContentSearchContextInput; /** Prepended to the indexed body so it shows up in result excerpts. */ descriptionField?: TDescription; imageField?: string; @@ -1375,6 +1441,17 @@ export interface ContentSearchConfig< titleField: TTitle; } +export interface ContentSearchContextInput { + field: string; + pathTemplate?: string; +} + +export interface ResolvedContentSearchContext { + field: string; + kind: "container" | "relation"; + pathTemplate: string; +} + export type ContentOptionEnabled = [TOption] extends [object | true] ? true : false; @@ -1393,6 +1470,7 @@ export interface ResolvedContentSearchConfig< > { authorField: null | string; contentFields: string[]; + context: null | ResolvedContentSearchContext; descriptionField: null | string; enabled: TEnabled; imageField: null | string; @@ -1852,6 +1930,7 @@ export interface ContentTypeDefinition< TIdStrategy extends ContentIdStrategy = ContentIdStrategy, TPublicWrite extends ResolvedContentPublicWriteConfig = ResolvedContentPublicWriteConfig, + TPublicPeople extends string = string, > { admin: ResolvedContentAdminConfig; /** Generated junction tables, child tables and the leaf-path mapping. */ @@ -1881,7 +1960,8 @@ export interface ContentTypeDefinition< publicApi: ResolvedContentPublicApiConfig< TPublicField, TPublicEnabled, - TPublicPath + TPublicPath, + TPublicPeople >; publication: ResolvedContentPublicationConfig; publicWrite: TPublicWrite; @@ -1904,12 +1984,15 @@ export interface ContentTypeDefinition< TVisibilityEnabled, TDuplicationEnabled, TIdStrategy, - TPublicWrite + TPublicWrite, + TPublicPeople > >; /** Search synchronization, or the disabled default when `search` is omitted. */ search: ResolvedContentSearchConfig; tableName: string; + /** Replies answering replies under one root, or the disabled default. */ + thread: ResolvedContentThreadConfig; visibility: ResolvedContentVisibilityConfig; } @@ -1936,7 +2019,9 @@ type ContentBindSelfRelations = { infer TNullable, infer TMultiple, infer TOrdered, - true + true, + ContentIdStrategy, + infer TContainer > ? ContentRelationField< TRequired, @@ -1944,7 +2029,8 @@ type ContentBindSelfRelations = { TMultiple, TOrdered, true, - TStrategy + TStrategy, + TContainer > : TFields[K]; }; @@ -2161,7 +2247,8 @@ export type ContentPublicSelect = Prettify< ContentPublicNested< ContentFieldsOf, ContentPublicFieldName - > & { + > & + ContentPublicPeopleColumns & { [ K in PublicFlatName> ]: ContentPublicValue< @@ -2172,6 +2259,33 @@ export type ContentPublicSelect = Prettify< } >; +/** The `publicApi.people` names of one content type, read off its resolved config. */ +export type ContentPublicPeopleName = TDefinition extends { + publicApi: { people: (infer TName extends string)[] }; +} + ? string extends TName + ? never + : TName + : never; + +/** + * What `defineContentType` records for `publicApi.people`. No people at all is + * `string`, the same as {@link AnyContentTypeDefinition}, so a definition + * without people stays comparable with the erased one; it publishes nothing + * because {@link ContentPublicPeopleName} reads `string` as "none". + */ +export type ContentPublicPeopleResolved = [ + TPeople, +] extends [never] + ? string + : TPeople; + +/** One {@link ContentPublicPerson} per `publicApi.people` entry. */ +type ContentPublicPeopleColumns = Record< + ContentPublicPeopleName, + ContentPublicPerson | null +>; + /** * The language a public row is actually in, on a localized content type. * @@ -2282,6 +2396,11 @@ export type ContentPublicUpdateBody = Prettify< export type ContentPublicWriteRecord = Prettify< ContentEditorialColumns & { + /** + * The record's moderation state as its author sees it: a pending or a + * rejected submission, or `null` when nothing is waiting. + */ + approval: ContentApprovalState | null; id: ContentIdOf; publishedAt: Date | null; slug: null | string; diff --git a/packages/vitnode/src/database/content-containers.ts b/packages/vitnode/src/database/content-containers.ts index a932be0bbc..94b979f809 100644 --- a/packages/vitnode/src/database/content-containers.ts +++ b/packages/vitnode/src/database/content-containers.ts @@ -25,6 +25,11 @@ export const core_content_containers = camelCase.table.withRLS( slug: t.varchar({ length: CONTENT_CONTAINER_SLUG_MAX_LENGTH }).notNull(), position: t.integer().notNull().default(0), metadata: t.jsonb().$type>().notNull().default({}), + policies: t + .jsonb() + .$type>>() + .notNull() + .default({}), createdAt: t.timestamp().notNull().defaultNow(), updatedAt: t .timestamp() diff --git a/packages/vitnode/src/database/content-submissions.ts b/packages/vitnode/src/database/content-submissions.ts new file mode 100644 index 0000000000..6ca1d85257 --- /dev/null +++ b/packages/vitnode/src/database/content-submissions.ts @@ -0,0 +1,66 @@ +import { sql } from "drizzle-orm"; +import { camelCase, index, uniqueIndex } from "drizzle-orm/pg-core"; + +import type { + ContentSubmissionKind, + ContentSubmissionStatus, +} from "../content/approvals/const"; + +import { + CONTENT_SUBMISSION_KINDS, + CONTENT_SUBMISSION_STATUSES, +} from "../content/approvals/const"; +import { core_users } from "./users"; + +export const core_content_submissions = camelCase.table.withRLS( + "core_content_submissions", + t => ({ + id: t.serial().primaryKey(), + pluginId: t.varchar({ length: 255 }).notNull(), + contentTypeId: t.varchar({ length: 100 }).notNull(), + itemId: t.varchar({ length: 64 }).notNull(), + containerDefinitionId: t.varchar({ length: 100 }), + containerId: t.integer(), + kind: t + .varchar({ enum: CONTENT_SUBMISSION_KINDS, length: 16 }) + .$type() + .notNull(), + status: t + .varchar({ enum: CONTENT_SUBMISSION_STATUSES, length: 16 }) + .$type() + .notNull() + .default("pending"), + values: t.jsonb().$type>().notNull().default({}), + baseValues: t.jsonb().$type>(), + baseVersion: t.integer(), + baseUpdatedAt: t.timestamp(), + authorId: t.integer().references(() => core_users.id, { + onDelete: "set null", + }), + reviewerId: t.integer().references(() => core_users.id, { + onDelete: "set null", + }), + reason: t.text(), + revisionId: t.integer(), + decidedAt: t.timestamp(), + createdAt: t.timestamp().notNull().defaultNow(), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + t => [ + uniqueIndex("core_content_submissions_pending_key") + .on(t.contentTypeId, t.itemId) + .where(sql`"status" = 'pending'`), + index("core_content_submissions_queue_idx").on( + t.status, + t.contentTypeId, + t.containerId, + t.createdAt, + ), + index("core_content_submissions_item_idx").on(t.contentTypeId, t.itemId), + index("core_content_submissions_author_idx").on(t.authorId, t.createdAt), + ], +); diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts index 1ed95f10c2..e1ef265a2d 100644 --- a/packages/vitnode/src/database/relations.ts +++ b/packages/vitnode/src/database/relations.ts @@ -4,6 +4,7 @@ import * as admins from "./admins"; import * as ai from "./ai"; import * as content from "./content"; import * as contentContainers from "./content-containers"; +import * as contentSubmissions from "./content-submissions"; import * as cron from "./cron"; import * as dashboard from "./dashboard"; import * as files from "./files"; @@ -26,6 +27,7 @@ export const coreSchema = { ...ai, ...content, ...contentContainers, + ...contentSubmissions, ...cron, ...dashboard, ...files, diff --git a/packages/vitnode/src/database/search.ts b/packages/vitnode/src/database/search.ts index fefb419f4c..8a7bdcde7d 100644 --- a/packages/vitnode/src/database/search.ts +++ b/packages/vitnode/src/database/search.ts @@ -115,5 +115,6 @@ export const core_search_index = camelCase.table.withRLS( index("core_search_index_language_code_idx").on(t.languageCode), index("core_search_index_is_public_idx").on(t.isPublic), index("core_search_index_image_id_idx").on(t.imageId), + index("core_search_index_container_idx").on(t.containerType, t.containerId), ], ); diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 63bef28a62..ac7b2f3ade 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -88,8 +88,15 @@ "newest": "Newest", "oldest": "Oldest" }, + "activity": { + "commented": "{author} commented on {context}", + "posted_in": "{author} posted {item} in {context}", + "commented_self": "Commented on {context}" + }, "types": { "blog_post": "Article", + "forum_comment": "Comment", + "forum_topic": "Topic", "unknown": "Content" }, "userTab": { @@ -155,6 +162,39 @@ "empty": "No collections found", "emptyDesc": "No collection matches your search. Try a different term." } + }, + "searchField": "Search posts, topics and comments…", + "clearSearch": "Clear search", + "results": "{count, plural, one {# result} other {# results}}", + "filters": { + "title": "Filters", + "active": "{count, plural, one {# filter on} other {# filters on}}", + "clearAll": "Clear all", + "reset": "Reset", + "content": "Content", + "date": "Date created", + "authors": "Authors", + "anyAuthor": "Any author", + "addAuthor": "Add", + "findAuthor": "Find an author", + "noAuthors": "No authors match “{term}”.", + "loadingAuthors": "Looking for authors…", + "removeAuthor": "Remove {name}", + "pickDates": "Pick a start and end day", + "done": "Done", + "customRange": "Custom range, {range}" + }, + "range": { + "all": "Any time", + "day": "Past 24 hours", + "week": "Past week", + "month": "Past month", + "year": "Past year", + "custom": "Custom range" + }, + "timeline": { + "items": "{count, plural, one {# item} other {# items}}", + "in_context": "in {context}" } }, "global": { @@ -1849,6 +1889,7 @@ "hasContent": "Content is still filed here. Move or delete that content first.", "invalidInput": "Some of the values aren't valid. Check the form and try again.", "invalidMetadata": "Some of the details don't match what this collection expects.", + "invalidPolicy": "Some publishing settings aren't valid for this collection.", "invalidPosition": "That position is outside the list. Refresh and try again.", "limitReached": "This collection is full. Delete something before adding more.", "maxDepth": "That would nest it deeper than this collection allows.", @@ -1858,6 +1899,56 @@ "slugTaken": "Another item in this collection already uses this slug.", "forbidden": "You don't have permission to do that.", "unknown": "Something went wrong. Try again in a moment." + }, + "policies": { + "action": "Publishing", + "title": "Publishing rules", + "desc": "Choose who can post in \"{name}\" and whether posts wait for approval. Anything on Inherit follows the parent or the collection's defaults.", + "submit": "Save rules", + "success": "Publishing rules saved", + "successDesc": "\"{name}\" now follows its new rules.", + "loadError": "Could not load the publishing rules. Check your connection and try again.", + "retry": "Try again", + "empty": "No content type lives in this collection yet, so there's nothing to set.", + "inherit": "Inherit (currently: {value})", + "inheritMinutes": "Inherit (currently: {count, plural, one {# minute} other {# minutes}})", + "source": { + "self": "Set on this {name}.", + "container": "Inherited from \"{name}\".", + "contentType": "Inherited from the collection's default for this type.", + "default": "Inherited from the collection default.", + "fallback": "Inherited from the content type's own default." + }, + "create": { + "label": "Who can start", + "desc": "Who can create new posts here.", + "members": "Members", + "moderators": "Moderators only" + }, + "publish": { + "label": "New posts", + "desc": "Whether new posts go live right away or wait for a moderator.", + "immediate": "Publish immediately", + "approve": "Wait for approval" + }, + "edits": { + "label": "Edits to published posts", + "desc": "Whether an author's changes to a live post show up right away.", + "immediate": "Apply immediately", + "approve": "Wait for approval" + }, + "authorEdit": { + "label": "Author editing", + "desc": "How long authors can edit their own posts.", + "always": "Always", + "never": "Never", + "window": "For a limited time" + }, + "authorEditMinutes": { + "label": "Editing window", + "desc": "How many minutes after posting an author can still edit.", + "unit": "minutes" + } } }, "editor": { @@ -2112,6 +2203,141 @@ "modcp": { "title": "Moderator CP", "nav": "Moderator CP", + "approvals": { + "title": "Approvals", + "desc": "New content and edits waiting for a moderator before they go live.", + "status_label": "Filter by status", + "statuses": { + "pending": "Pending", + "approved": "Approved", + "rejected": "Rejected", + "superseded": "Replaced", + "cancelled": "Cancelled" + }, + "kinds": { + "create": "New", + "edit": "Edit" + }, + "filters": { + "type": "Type", + "all_types": "All types", + "container": "Location", + "all_containers": "Everywhere" + }, + "untitled": "Untitled", + "deleted_user": "Deleted account", + "reason_preview": "Reason: {reason}", + "empty": { + "pending_title": "All caught up", + "pending_desc": "Nothing is waiting for approval. Go grab a coffee.", + "title": "Nothing here", + "desc": "No submissions match these filters." + }, + "approve": { + "button": "Approve", + "success": "Approved", + "success_desc": "“{title}” is live now." + }, + "reject": { + "button": "Reject", + "title": "Reject this submission?", + "desc": "Nothing on the site changes. The author sees your reason and can try again.", + "reason": "Reason", + "reason_desc": "Optional, and shown to the author - keep it kind and specific.", + "submit": "Reject submission", + "success": "Rejected", + "success_desc": "“{title}” stays as it was." + }, + "errors": { + "decided": { + "title": "Already decided", + "desc": "Another moderator got here first. The queue shows what they decided." + }, + "stale": { + "title": "The record changed", + "desc": "The record changed since this was proposed - reject it and ask the author to resubmit." + }, + "gone": { + "title": "The content is gone", + "desc": "It was deleted before anyone decided, so the submission was cancelled." + }, + "forbidden": { + "title": "Out of your reach", + "desc": "The content moved somewhere you cannot approve in." + }, + "notFound": { + "title": "Submission not found", + "desc": "It no longer exists, or it is outside what you moderate." + }, + "rate_limited": { + "title": "Easy there", + "desc": "That was a lot of decisions at once. Try again in a moment." + }, + "unknown": { + "title": "Something went wrong", + "desc": "The decision was not saved. Please try again." + } + }, + "detail": { + "title": "Submission #{id}", + "subtitle": "{type}", + "subtitle_in": "{type} in {container}", + "sections": { + "proposed": "Proposed content", + "changes": "Changes", + "history": "History" + }, + "history_empty": "This is the first submission for this content.", + "empty": "(empty)", + "yes": "Yes", + "no": "No", + "changed": "Changed", + "live": "Live now", + "proposed": "Proposed", + "no_changes": "The proposal matches the live version, so nothing would change.", + "unchanged": "{count, plural, one {# unchanged field} other {# unchanged fields}}", + "view_live": "View live", + "view_draft": "Open draft", + "new_tab": "(opens in a new tab)", + "details": { + "title": "Details", + "author": "Author", + "deleted_user": "Deleted account", + "type": "Type", + "container": "Location", + "kind": "Kind", + "status": "Status", + "reviewer": "Reviewer", + "submitted": "Submitted", + "decided": "Decided" + }, + "decided": { + "approved": { + "title": "Approved", + "desc": "This submission went live." + }, + "rejected": { + "title": "Rejected", + "desc": "The record stayed as it was." + }, + "superseded": { + "title": "Replaced", + "desc": "The author sent a newer version, which took this one's place." + }, + "cancelled": { + "title": "Cancelled", + "desc": "The content was deleted before anyone decided." + }, + "reason": "Reason: {reason}" + }, + "warnings": { + "gone_title": "The content is gone", + "gone_desc": "It was deleted after this was submitted, so it can only be rejected.", + "stale_title": "The live version changed", + "stale_desc": "Someone edited the record after this was proposed - reject it and ask the author to resubmit." + } + } + }, "reports": { "title": "Reports", "desc": "Reports you can act on. Drag a card to another column to take it, close it or send it back.", diff --git a/packages/vitnode/src/pages/modcp/approval.tsx b/packages/vitnode/src/pages/modcp/approval.tsx new file mode 100644 index 0000000000..9ef83ffc0b --- /dev/null +++ b/packages/vitnode/src/pages/modcp/approval.tsx @@ -0,0 +1,24 @@ +import type { PluginRoutePageProps } from "@/routing"; + +import type { ModcpApprovalRouteData } from "../../tanstack/moderation/route"; + +import { ModcpApprovalRouteContent } from "../../tanstack/moderation/approval-screen"; +import { ModcpApprovalBreadcrumb } from "../../tanstack/moderation/breadcrumb"; +import { loadModcpApprovalRoute } from "../../tanstack/moderation/route"; +import { defineAuthenticatedRoute } from "../../tanstack/plugin-routes"; + +const ModcpApprovalPage = ({ + loaderData, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAuthenticatedRoute({ + load: async ({ context, params, t }) => + await loadModcpApprovalRoute({ ...context, id: params.id ?? "", t }), + head: ({ loaderData }) => ({ robots: "noindex, nofollow", ...loaderData }), + + breadcrumb: ModcpApprovalBreadcrumb, +}); + +export default ModcpApprovalPage; diff --git a/packages/vitnode/src/pages/modcp/approvals.tsx b/packages/vitnode/src/pages/modcp/approvals.tsx new file mode 100644 index 0000000000..9dee3cd709 --- /dev/null +++ b/packages/vitnode/src/pages/modcp/approvals.tsx @@ -0,0 +1,34 @@ +import type { PluginRoutePageProps } from "@/routing"; + +import type { ModcpApprovalsRouteSearch } from "../../tanstack/moderation/approvals-route-search"; +import type { ModcpRouteData } from "../../tanstack/moderation/route"; + +import { ModcpApprovalsRouteContent } from "../../tanstack/moderation/approvals-screen"; +import { ModcpApprovalsBreadcrumb } from "../../tanstack/moderation/breadcrumb"; +import { loadModcpApprovalsRoute } from "../../tanstack/moderation/route"; +import { defineAuthenticatedRoute } from "../../tanstack/plugin-routes"; + +const ModcpApprovalsPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAuthenticatedRoute< + ModcpRouteData, + ModcpApprovalsRouteSearch +>({ + load: async ({ context, search, t }) => + await loadModcpApprovalsRoute({ ...context, search, t }), + head: ({ loaderData }) => ({ robots: "noindex, nofollow", ...loaderData }), + + breadcrumb: ModcpApprovalsBreadcrumb, +}); + +export default ModcpApprovalsPage; diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index 45787d0fd1..f223f0d14d 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -46,6 +46,7 @@ import { } from "./tanstack/auth/verify-email"; import { MY_FILES_NAMESPACES } from "./tanstack/files/route"; import { normalizeMyFilesRouteSearch } from "./tanstack/files/route-search"; +import { normalizeModcpApprovalsSearch } from "./tanstack/moderation/approvals-route-search"; import { MODCP_NAMESPACES } from "./tanstack/moderation/namespaces"; import { normalizeModcpReportsSearch } from "./tanstack/moderation/route-search"; import { NOTIFICATIONS_NAMESPACES } from "./tanstack/notifications/namespaces"; @@ -206,6 +207,17 @@ export const routes = defineRoutes([ component: lazy(() => import("./pages/modcp/report")), pendingComponent: () => , }), + + page("approvals", { + component: lazy(() => import("./pages/modcp/approvals")), + pendingComponent: () => , + search: normalizeModcpApprovalsSearch, + }), + + page("approvals/:id", { + component: lazy(() => import("./pages/modcp/approval")), + pendingComponent: () => , + }), ], }), diff --git a/packages/vitnode/src/routing/index.ts b/packages/vitnode/src/routing/index.ts index 987480666a..77aca1a799 100644 --- a/packages/vitnode/src/routing/index.ts +++ b/packages/vitnode/src/routing/index.ts @@ -32,6 +32,7 @@ export type { } from "./module"; export { readPluginRouteModule } from "./module"; export { + fitNamespaceList, MAX_NAMESPACE_DEPTH, MAX_NAMESPACE_LENGTH, MAX_NAMESPACES, diff --git a/packages/vitnode/src/routing/namespaces.test.ts b/packages/vitnode/src/routing/namespaces.test.ts index 68bc055f4b..39a95fc5f5 100644 --- a/packages/vitnode/src/routing/namespaces.test.ts +++ b/packages/vitnode/src/routing/namespaces.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + fitNamespaceList, MAX_NAMESPACE_DEPTH, MAX_NAMESPACE_LENGTH, namespaceProblem, @@ -90,3 +91,37 @@ describe("normalizeNamespaceList", () => { expect(normalizeNamespaceList([])).toEqual([]); }); }); + +describe("fitNamespaceList", () => { + const namespaces = [ + "@vitnode/blog.post.list", + "@vitnode/blog.post.view", + "@vitnode/blog.tag.view", + "core.global", + "core.global", + ]; + + it.each<[string, number, string[]]>([ + [ + "leaves a list under budget alone, beyond normalising it", + 10, + [ + "@vitnode/blog.post.list", + "@vitnode/blog.post.view", + "@vitnode/blog.tag.view", + "core.global", + ], + ], + [ + "coarsens to two segments when that is enough", + 3, + ["@vitnode/blog.post", "@vitnode/blog.tag", "core.global"], + ], + ["falls back to the plugin root", 2, ["@vitnode/blog", "core"]], + // Dropping a namespace would render its strings as keys, so the plugin + // root is the floor even when it still overshoots. + ["stops at the plugin root even over budget", 1, ["@vitnode/blog", "core"]], + ])("%s", (_label, budget, expected) => { + expect(fitNamespaceList(namespaces, budget)).toEqual(expected); + }); +}); diff --git a/packages/vitnode/src/routing/namespaces.ts b/packages/vitnode/src/routing/namespaces.ts index 40687d7cf6..c3454b5e98 100644 --- a/packages/vitnode/src/routing/namespaces.ts +++ b/packages/vitnode/src/routing/namespaces.ts @@ -60,3 +60,29 @@ export const normalizeNamespaceList = ( return a < b ? -1 : 1; }); + +const coarsen = (namespace: string, depth: number): string => + namespace.split(".").slice(0, depth).join("."); + +/** + * Shrinks a namespace list to at most `budget` entries by asking for broader + * subtrees: first each namespace as it is, then its first two segments + * (`@vitnode/example.content`), then the plugin root (`@vitnode/example`). A + * broader namespace holds every narrower one, so nothing renders as a key - the + * cost is a few more strings in the response. + */ +export const fitNamespaceList = ( + namespaces: readonly string[], + budget: number = MAX_NAMESPACES, +): string[] => { + let fitted = normalizeNamespaceList(namespaces); + + for (const depth of [2, 1]) { + if (fitted.length <= budget) return fitted; + fitted = normalizeNamespaceList( + fitted.map(namespace => coarsen(namespace, depth)), + ); + } + + return fitted; +}; diff --git a/packages/vitnode/src/tanstack/admin/containers/screen.tsx b/packages/vitnode/src/tanstack/admin/containers/screen.tsx index d2af1fcecc..f9848ba833 100644 --- a/packages/vitnode/src/tanstack/admin/containers/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/containers/screen.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "use-intl"; import type { ContentLabelTranslator } from "@/content/admin/labels"; import type { RegisteredFrontendContentContainer } from "@/content/admin/registry"; import type { ContentContainerSave } from "@/views/admin/views/containers/container-form-content"; +import type { ContentContainerPoliciesSave } from "@/views/admin/views/containers/container-policies-form"; import type { ContentContainerMutationResult } from "@/views/admin/views/containers/container-request"; import { contentContainerLabelsFrom } from "@/content/admin/containers"; @@ -16,6 +17,7 @@ import { deleteContentContainer, moveContentContainer, updateContentContainer, + updateContentContainerPolicies, } from "@/views/admin/views/containers/container-request"; import type { ContentContainerRouteData } from "./route"; @@ -53,6 +55,12 @@ const useContentContainerMutations = ( : await updateContentContainer(target, id, body), ); + const onSavePolicies: ContentContainerPoliciesSave = async ({ + id, + overrides, + }) => + await settle(await updateContentContainerPolicies(target, id, overrides)); + return { onDelete: async (id: number) => await settle(await deleteContentContainer(target, id)), @@ -65,6 +73,7 @@ const useContentContainerMutations = ( position: number; }) => await settle(await moveContentContainer(target, id, move)), onSave, + onSavePolicies, }; }, [entry, queryClient]); }; diff --git a/packages/vitnode/src/tanstack/admin/content/row-actions.test.tsx b/packages/vitnode/src/tanstack/admin/content/row-actions.test.tsx index 3b1d3e1481..b98c4a6352 100644 --- a/packages/vitnode/src/tanstack/admin/content/row-actions.test.tsx +++ b/packages/vitnode/src/tanstack/admin/content/row-actions.test.tsx @@ -1,5 +1,4 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { requestHandler } from "@tanstack/react-start/server"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { toast } from "sonner"; import { IntlProvider } from "use-intl"; @@ -20,6 +19,7 @@ import type { ContentRowData } from "@/views/admin/views/content/table/cells"; import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider"; import { defineContentType } from "@/content/define"; import { field } from "@/content/fields"; +import { runInStartRequest } from "@/tests/start-request"; import type { ContentDuplicatedRecord } from "./row-actions"; @@ -116,11 +116,7 @@ const mount = ({ const API_ORIGIN = "http://api.test"; aroundEach(async runTest => { - await requestHandler(async () => { - await runTest(); - - return new Response(null, { status: 204 }); - })(new Request(`${API_ORIGIN}/admin/content/blog/articles`), undefined); + await runInStartRequest(`${API_ORIGIN}/admin/content/blog/articles`, runTest); }); beforeEach(() => { diff --git a/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx b/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx index 062c0eb9cf..3b024e94d3 100644 --- a/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx +++ b/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx @@ -43,7 +43,7 @@ const UserTimeline = ({ adminUserTimelineQueryOptions({ adminUserId, locale, userId }), ); - return ; + return ; }; const UserAiAccess = ({ diff --git a/packages/vitnode/src/tanstack/moderation/approval-screen.tsx b/packages/vitnode/src/tanstack/moderation/approval-screen.tsx new file mode 100644 index 0000000000..ae2fb9fc47 --- /dev/null +++ b/packages/vitnode/src/tanstack/moderation/approval-screen.tsx @@ -0,0 +1,10 @@ +import { ModcpApprovalContent } from "@/views/moderation/modcp/approvals/approval-content"; + +import { RouteMessages } from "../i18n/route-messages"; +import { MODCP_NAMESPACES } from "./namespaces"; + +export const ModcpApprovalRouteContent = ({ id }: { id: number }) => ( + + + +); diff --git a/packages/vitnode/src/tanstack/moderation/approvals-route-search.ts b/packages/vitnode/src/tanstack/moderation/approvals-route-search.ts new file mode 100644 index 0000000000..2418ad9a38 --- /dev/null +++ b/packages/vitnode/src/tanstack/moderation/approvals-route-search.ts @@ -0,0 +1,81 @@ +import type { + ModcpApprovalsFilter, + ModcpApprovalStatus, +} from "@/views/moderation/modcp/modcp-query"; + +import { asSearchValue, readPageSize } from "@/lib/table-params"; +import { MODCP_APPROVAL_STATUSES } from "@/views/moderation/modcp/modcp-query"; + +export const MODCP_APPROVALS_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; +const MAX_PAGE = 10_000; +const MAX_TYPE_LENGTH = 100; + +export interface ModcpApprovalsRouteSearch { + container?: number; + first?: number; + page?: number; + /** `pending` is what the URL says by saying nothing. */ + status?: Exclude; + type?: string; +} + +const positiveInt = (raw: string | undefined, max: number) => { + if (raw === undefined || !/^\d+$/.test(raw)) return undefined; + const value = Number(raw); + + return Number.isSafeInteger(value) && value > 0 && value <= max + ? value + : undefined; +}; + +const isStatus = (value: string): value is ModcpApprovalStatus => + MODCP_APPROVAL_STATUSES.some(status => status === value); + +export const normalizeModcpApprovalsSearch = ( + input: Record, +): ModcpApprovalsRouteSearch => { + const status = asSearchValue(input.status); + const type = asSearchValue(input.type)?.trim(); + const container = positiveInt(asSearchValue(input.container), 2_147_483_647); + const page = positiveInt(asSearchValue(input.page), MAX_PAGE); + const rawFirst = asSearchValue(input.first); + const first = + rawFirst === undefined ? undefined : readPageSize(rawFirst, MAX_PAGE_SIZE); + + return { + ...(status !== undefined && isStatus(status) && status !== "pending" + ? { status } + : {}), + ...(type && type.length <= MAX_TYPE_LENGTH ? { type } : {}), + ...(container === undefined ? {} : { container }), + ...(page === undefined || page === 1 ? {} : { page }), + ...(first === undefined || Number(first) === MODCP_APPROVALS_PAGE_SIZE + ? {} + : { first: Number(first) }), + }; +}; + +export const modcpApprovalsFilter = ( + search: ModcpApprovalsRouteSearch, +): ModcpApprovalsFilter => ({ + page: search.page ?? 1, + pageSize: search.first ?? MODCP_APPROVALS_PAGE_SIZE, + status: search.status ?? "pending", + ...(search.type === undefined ? {} : { contentTypeId: search.type }), + ...(search.container === undefined ? {} : { containerId: search.container }), +}); + +export const modcpApprovalsSearchParams = ( + search: ModcpApprovalsRouteSearch, +): URLSearchParams => + new URLSearchParams( + Object.entries(search) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, String(value)]), + ); + +export const modcpApprovalsSearchFrom = ( + query: string, +): ModcpApprovalsRouteSearch => + normalizeModcpApprovalsSearch(Object.fromEntries(new URLSearchParams(query))); diff --git a/packages/vitnode/src/tanstack/moderation/approvals-screen.tsx b/packages/vitnode/src/tanstack/moderation/approvals-screen.tsx new file mode 100644 index 0000000000..51300ff0fd --- /dev/null +++ b/packages/vitnode/src/tanstack/moderation/approvals-screen.tsx @@ -0,0 +1,55 @@ +import React from "react"; + +import type { DataTableNavigation } from "@/components/table/navigation"; + +import { DataTableNavigationProvider } from "@/components/table/navigation"; +import { ModcpApprovalsContent } from "@/views/moderation/modcp/approvals/approvals-content"; + +import type { ModcpApprovalsRouteSearch } from "./approvals-route-search"; +import type { ModcpRouteData } from "./route"; + +import { RouteMessages } from "../i18n/route-messages"; +import { + modcpApprovalsFilter, + modcpApprovalsSearchFrom, + modcpApprovalsSearchParams, +} from "./approvals-route-search"; +import { MODCP_NAMESPACES } from "./namespaces"; + +export const ModcpApprovalsRouteContent = ({ + description, + navigate, + search, + title, +}: ModcpRouteData & { + navigate: (options: { + resetScroll?: boolean; + search: ModcpApprovalsRouteSearch; + }) => Promise; + search: ModcpApprovalsRouteSearch; +}) => { + const navigation = React.useMemo( + () => ({ + navigate: async nextSearch => { + await navigate({ + resetScroll: false, + search: modcpApprovalsSearchFrom(nextSearch), + }); + }, + searchParams: modcpApprovalsSearchParams(search), + }), + [navigate, search], + ); + + return ( + + + + + + ); +}; diff --git a/packages/vitnode/src/tanstack/moderation/breadcrumb.tsx b/packages/vitnode/src/tanstack/moderation/breadcrumb.tsx index 1d3eb9c60d..2d28b347f8 100644 --- a/packages/vitnode/src/tanstack/moderation/breadcrumb.tsx +++ b/packages/vitnode/src/tanstack/moderation/breadcrumb.tsx @@ -21,3 +21,19 @@ export const ModcpReportBreadcrumb = ({ return <>{t("title", { id: loaderData?.id ?? 0 })}; }; + +export const ModcpApprovalsBreadcrumb = () => { + const t = useTranslations("core.moderation.modcp.approvals"); + + return <>{t("title")}; +}; + +export const ModcpApprovalBreadcrumb = ({ + loaderData, +}: { + loaderData?: { id: number }; +}) => { + const t = useTranslations("core.moderation.modcp.approvals.detail"); + + return <>{t("title", { id: loaderData?.id ?? 0 })}; +}; diff --git a/packages/vitnode/src/tanstack/moderation/route.ts b/packages/vitnode/src/tanstack/moderation/route.ts index 88c5ec6271..5c386391de 100644 --- a/packages/vitnode/src/tanstack/moderation/route.ts +++ b/packages/vitnode/src/tanstack/moderation/route.ts @@ -7,14 +7,19 @@ import type { BoardPerson } from "@/views/moderation/modcp/board/board-moves"; import { MODCP_BOARD_STATUSES, + modcpApprovalFiltersQueryOptions, + modcpApprovalQueryOptions, + modcpApprovalsQueryOptions, modcpBoardColumnQueryOptions, modcpReportQueryOptions, ModcpRequestError, } from "@/views/moderation/modcp/modcp-query"; import type { AuthenticatedState } from "../auth/state"; +import type { ModcpApprovalsRouteSearch } from "./approvals-route-search"; import type { ModcpReportsRouteSearch } from "./route-search"; +import { modcpApprovalsFilter } from "./approvals-route-search"; import { modcpBoardFilter } from "./route-search"; export interface ModcpRouteData { @@ -33,6 +38,30 @@ export const requireModeratorRoute = (auth: AuthenticatedState): void => { } }; +/** A serial id from the URL, or the route's 404. */ +const routeIdOf = (raw: string): number => { + const id = /^[1-9]\d{0,9}$/.test(raw) ? Number(raw) : Number.NaN; + if (!Number.isSafeInteger(id) || id > 2_147_483_647) { + // oxlint-disable-next-line typescript/only-throw-error + throw notFound(); + } + + return id; +}; + +/** Turns the API's 404 - which also covers "not in your scope" - into the route's. */ +const withNotFound = async (load: () => Promise): Promise => { + try { + await load(); + } catch (error) { + if (error instanceof ModcpRequestError && error.status === 404) { + // oxlint-disable-next-line typescript/only-throw-error + throw notFound(); + } + throw error; + } +}; + export const loadModcpReportsRoute = async ({ auth, queryClient, @@ -89,24 +118,15 @@ export const loadModcpReportRoute = async ({ t: PluginRouteTranslator; }): Promise => { requireModeratorRoute(auth); - const id = /^[1-9]\d{0,9}$/.test(raw) ? Number(raw) : Number.NaN; - if (!Number.isSafeInteger(id) || id > 2_147_483_647) { - // oxlint-disable-next-line typescript/only-throw-error - throw notFound(); - } + const id = routeIdOf(raw); - try { - await queryClient.query({ - ...modcpReportQueryOptions(id), - staleTime: "static", - }); - } catch (error) { - if (error instanceof ModcpRequestError && error.status === 404) { - // oxlint-disable-next-line typescript/only-throw-error - throw notFound(); - } - throw error; - } + await withNotFound( + async () => + await queryClient.query({ + ...modcpReportQueryOptions(id), + staleTime: "static", + }), + ); return { description: t("core.moderation.modcp.reports.desc"), @@ -115,3 +135,65 @@ export const loadModcpReportRoute = async ({ userId: auth.user.id, }; }; + +export const loadModcpApprovalsRoute = async ({ + auth, + queryClient, + search, + t, +}: { + auth: AuthenticatedState; + queryClient: QueryClient; + search: ModcpApprovalsRouteSearch; + t: PluginRouteTranslator; +}): Promise => { + requireModeratorRoute(auth); + await Promise.all([ + queryClient.query({ + ...modcpApprovalsQueryOptions(modcpApprovalsFilter(search)), + staleTime: "static", + }), + queryClient.query({ + ...modcpApprovalFiltersQueryOptions(), + staleTime: "static", + }), + ]); + + return { + description: t("core.moderation.modcp.approvals.desc"), + title: t("core.moderation.modcp.approvals.title"), + }; +}; + +export interface ModcpApprovalRouteData extends ModcpRouteData { + id: number; +} + +export const loadModcpApprovalRoute = async ({ + auth, + id: raw, + queryClient, + t, +}: { + auth: AuthenticatedState; + id: string; + queryClient: QueryClient; + t: PluginRouteTranslator; +}): Promise => { + requireModeratorRoute(auth); + const id = routeIdOf(raw); + + await withNotFound( + async () => + await queryClient.query({ + ...modcpApprovalQueryOptions(id), + staleTime: "static", + }), + ); + + return { + description: t("core.moderation.modcp.approvals.desc"), + id, + title: t("core.moderation.modcp.approvals.detail.title", { id }), + }; +}; diff --git a/packages/vitnode/src/tanstack/profile/screen.tsx b/packages/vitnode/src/tanstack/profile/screen.tsx index 487cdbd6fd..bcd4d4452f 100644 --- a/packages/vitnode/src/tanstack/profile/screen.tsx +++ b/packages/vitnode/src/tanstack/profile/screen.tsx @@ -64,7 +64,7 @@ const ProfileTimeline = ({ > {t("timeline")} - + ); }; diff --git a/packages/vitnode/src/tanstack/search/search-screen.tsx b/packages/vitnode/src/tanstack/search/search-screen.tsx index 60d49be94d..f8ca024815 100644 --- a/packages/vitnode/src/tanstack/search/search-screen.tsx +++ b/packages/vitnode/src/tanstack/search/search-screen.tsx @@ -17,7 +17,7 @@ export const SearchRouteContent = ({ return ( -
+
testPolicyTopicContentType, + required: true, + container: true, + onDelete: "cascade", + }), + parent: field.relation({ + self: true, + nullable: true, + onDelete: "set null", + }), + }, + publicApi: { + path: "policy-replies", + fields: ["id", "slug", "body", "topic", "parent", "publishedAt"], + }, + publicWrite: { + ownership: { field: "author" }, + create: { fields: ["body", "topic", "parent"], status: "published" }, + update: { fields: ["body"] }, + }, + thread: { parent: "parent", root: "topic", maxDepth: 4 }, +}); diff --git a/packages/vitnode/src/tests/content-policies.ts b/packages/vitnode/src/tests/content-policies.ts new file mode 100644 index 0000000000..ec2d27a2a4 --- /dev/null +++ b/packages/vitnode/src/tests/content-policies.ts @@ -0,0 +1,303 @@ +import type { Table } from "drizzle-orm"; +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; + +import { moderationModule } from "@/api/modules/moderation/moderation.module"; +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { createContentModel } from "@/content/server/model"; +import { buildContentPublicRoutes } from "@/content/server/public-routes"; +import { core_content_revisions } from "@/database/content"; +import { core_content_containers } from "@/database/content-containers"; +import { core_content_submissions } from "@/database/content-submissions"; +import { core_files } from "@/database/files"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { + testPolicyBoardContainers, + testPolicyReplyContentType, + testPolicyTopicContentType, +} from "@/tests/content-fixtures"; +import { createMemoryDb } from "@/tests/memory-db"; +import { + grantStaffPermissions, + ROOT_STAFF_PERMISSIONS, +} from "@/tests/staff-permissions"; + +/** + * One world for the container policy, approval and thread route tests: a board + * collection with policies, topics filed in a board, replies filed in their + * topic's board, and moderators scoped to some of the boards. + */ +export const POLICY_PLUGIN_ID = "@vitnode/example"; + +export const policyTopics = createContentModel(testPolicyTopicContentType); +export const policyReplies = createContentModel(testPolicyReplyContentType, { + references: { topic: () => policyTopics.table.id }, +}); + +const FAMILY = testPolicyBoardContainers.id; +const TOPIC_TYPE = testPolicyTopicContentType.id; +const REPLY_TYPE = testPolicyReplyContentType.id; + +export const BOARD = { + /** Immediate everything, from the definition defaults. */ + OPEN: 1, + /** Below OPEN, no overrides of its own. */ + OPEN_CHILD: 2, + /** New topics and replies, and topic edits, wait for a moderator. */ + APPROVE: 3, + /** Only `can_edit` holders start topics here. */ + MODERATORS: 4, + /** Authors never edit their topics here. */ + LOCKED: 5, +} as const; + +export const USER = { + MEMBER: 1, + OTHER_MEMBER: 2, + /** `can_edit` and `can_approve` in OPEN only - not below it. */ + MOD_OPEN: 10, + /** `can_approve` in OPEN and everything below it. */ + MOD_OPEN_TREE: 11, + /** `can_edit` and `can_approve` in APPROVE. */ + MOD_APPROVE: 12, + ROOT: 99, +} as const; + +const MEMBER_ROLE = 3; + +export const POLICY_LIVE = new Date("2026-09-01T10:00:00.000Z"); + +const scoped = ( + permission: string, + ids: string[], + contentTypes = [policyTopics, policyReplies], +): PermissionsStaffArgs[] => + contentTypes.map(model => ({ + module: model.definition.permissionModule, + permission, + plugin: POLICY_PLUGIN_ID, + scope: { family: FAMILY, ids, mode: "selected" }, + })); + +const GRANTS: [ + number, + Parameters[1]["permissions"], +][] = [ + [USER.MEMBER, { permissions: [], root: false, staff: false }], + [USER.OTHER_MEMBER, { permissions: [], root: false, staff: false }], + [ + USER.MOD_OPEN, + [ + ...scoped(CONTENT_PERMISSIONS.edit, [String(BOARD.OPEN)]), + ...scoped(CONTENT_PERMISSIONS.approve, [String(BOARD.OPEN)]), + ], + ], + [ + USER.MOD_OPEN_TREE, + scoped(CONTENT_PERMISSIONS.approve, [`${BOARD.OPEN}:tree`]), + ], + [ + USER.MOD_APPROVE, + [ + ...scoped(CONTENT_PERMISSIONS.edit, [String(BOARD.APPROVE)]), + ...scoped(CONTENT_PERMISSIONS.approve, [String(BOARD.APPROVE)]), + ], + ], + [USER.ROOT, ROOT_STAFF_PERMISSIONS], +]; + +const board = ( + id: number, + parentId: null | number, + policies: Record> = {}, +) => ({ + createdAt: POLICY_LIVE, + definitionId: FAMILY, + id, + metadata: {}, + name: `Board ${id}`, + parentId, + pluginId: POLICY_PLUGIN_ID, + policies, + position: id, + slug: `board-${id}`, + updatedAt: POLICY_LIVE, +}); + +export const policyBoardRows = () => [ + board(BOARD.OPEN, null), + board(BOARD.OPEN_CHILD, BOARD.OPEN), + board(BOARD.APPROVE, null, { + [TOPIC_TYPE]: { edits: "approve", publish: "approve" }, + [REPLY_TYPE]: { publish: "approve" }, + }), + board(BOARD.MODERATORS, null, { [TOPIC_TYPE]: { create: "moderators" } }), + board(BOARD.LOCKED, null, { [TOPIC_TYPE]: { authorEdit: "never" } }), +]; + +export const policyTopicRow = ( + id: number, + boardId: number, + overrides: Record = {}, +) => ({ + author: USER.MEMBER, + board: boardId, + createdAt: POLICY_LIVE, + hiddenAt: null, + hiddenBy: null, + id, + publishedAt: POLICY_LIVE, + slug: `topic-${id}`, + status: "published", + title: `Topic ${id}`, + updatedAt: POLICY_LIVE, + version: 1, + ...overrides, +}); + +export const policyReplyRow = ( + id: number, + topic: number, + overrides: Record = {}, +) => ({ + author: USER.MEMBER, + body: `Reply ${id}`, + createdAt: new Date(POLICY_LIVE.getTime() + id * 60_000), + hiddenAt: null, + hiddenBy: null, + id, + parent: null, + publishedAt: POLICY_LIVE, + slug: `reply-${id}`, + status: "published", + topic, + updatedAt: POLICY_LIVE, + ...overrides, +}); + +export const createPolicyWorld = async ({ + replies = [], + submissions = [], + topics = [], +}: { + replies?: readonly object[]; + submissions?: readonly object[]; + topics?: readonly object[]; +} = {}) => { + const users = Object.values(USER); + const memory = createMemoryDb([ + [core_content_containers, policyBoardRows()], + [policyTopics.table, topics], + [policyReplies.table, replies], + [core_content_submissions, submissions], + [core_content_revisions, []], + [ + core_users, + users.map(id => ({ + avatarColor: "000000", + avatarId: null, + email: `${id}@example.com`, + id, + name: `User ${id}`, + nameCode: `user-${id}`, + roleId: MEMBER_ROLE, + })), + ], + [core_roles, [{ color: null, id: MEMBER_ROLE, prefix: null }]], + [core_files, []], + ] as [Table, readonly object[]][]); + + const cache = createTestCache(); + for (const [userId, permissions] of GRANTS) { + await grantStaffPermissions(cache, { + permissions, + type: "moderator", + userId, + }); + } + + const emitted: { name: string; payload: Record }[] = []; + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + const header = c.req.header("x-user"); + c.set( + "user", + (header + ? { id: Number(header), roleId: MEMBER_ROLE } + : null) as unknown as Context["var"]["user"], + ); + c.set("cache", cache); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { + emit: async (name: string, payload: Record) => { + emitted.push({ name, payload }); + + return await Promise.resolve({ + delivered: 0, + eventId: "event", + failures: [], + }); + }, + } as unknown as Context["var"]["events"]); + c.set("core", { + contentModels: [ + { model: policyTopics, pluginId: POLICY_PLUGIN_ID }, + { model: policyReplies, pluginId: POLICY_PLUGIN_ID }, + ], + moderation: { scopes: [], targets: [] }, + permissionStaff: [], + } as unknown as Context["var"]["core"]); + await next(); + }); + app.onError((error, c) => + error instanceof HTTPException + ? error.getResponse() + : c.json({ error: String(error) }, 500), + ); + + for (const [path, model] of [ + ["/topics", policyTopics], + ["/replies", policyReplies], + ] as const) { + const sub = new OpenAPIHono(); + for (const { handler, route } of buildContentPublicRoutes(model as never, { + pluginId: POLICY_PLUGIN_ID, + })) { + sub.openapi(route, handler); + } + app.route(path, sub); + } + app.route("/moderation", moderationModule.hono); + + const send = async ( + method: string, + path: string, + { body, user }: { body?: unknown; user?: number } = {}, + ) => + await app.request(path, { + method, + headers: { + "content-type": "application/json", + ...(user === undefined ? {} : { "x-user": String(user) }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + return { + emitted, + events: () => emitted.map(event => event.name), + memory, + rows: (table: Table) => memory.rows(table), + send, + submissions: () => memory.rows(core_content_submissions), + }; +}; + +export type PolicyWorld = Awaited>; diff --git a/packages/vitnode/src/tests/start-request.ts b/packages/vitnode/src/tests/start-request.ts new file mode 100644 index 0000000000..66076f4b4e --- /dev/null +++ b/packages/vitnode/src/tests/start-request.ts @@ -0,0 +1,19 @@ +import { createServerEntry } from "@tanstack/react-start/server"; + +export const runInStartRequest = async ( + url: string, + run: () => Promise, +): Promise => { + let outcome: Promise = Promise.resolve(); + + await createServerEntry({ + fetch: async () => { + outcome = run(); + await outcome.catch(() => undefined); + + return new Response(null, { status: 204 }); + }, + }).fetch(new Request(url)); + + await outcome; +}; diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx index 79e8368eb2..c5673f15a6 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx @@ -29,7 +29,7 @@ import { } from "@/content/admin/labels"; import { CONTENT_PERMISSIONS } from "@/content/const"; import { contentAdminHref } from "@/content/registry"; -import { normalizeNamespaceList } from "@/routing"; +import { fitNamespaceList, MAX_NAMESPACES } from "@/routing"; /** A resolved sub-item, as the sidebar and the search index read it. */ export interface AdminNavSubItem { @@ -423,16 +423,18 @@ const titleNamespace = (title: AdminNavTitle): string => { * plugin route's namespaces go through, so two callers with the same navigation * ask for one cache entry rather than two holding identical bytes. * - * Note the budget: `MAX_NAMESPACES` caps a single request at 16, which is the - * shell's two plus roughly three per plugin that contributes navigation - a - * heading, its content types and its declared entries. An installation large - * enough to exceed that wants a coarser projection, and it will be told so - * rather than quietly served half a sidebar. + * Note the budget: `MAX_NAMESPACES` caps a single request at 16, and the shell + * and the navigation screen add their own few. Three plugins with a handful of + * content types each already pass it, so the list is coarsened to fit - a + * plugin's whole `content` subtree, then its root - rather than refused. */ +/** What the shell (`core.global`, `admin.global`) and a screen leave over. */ +const ADMIN_NAV_NAMESPACE_BUDGET = MAX_NAMESPACES - 4; + export const adminNavNamespaces = ( declarations: readonly AdminNavGroupDeclaration[], ): string[] => - normalizeNamespaceList( + fitNamespaceList( declarations.flatMap(group => [ titleNamespace(group.title), ...group.items.flatMap(item => [ @@ -440,6 +442,7 @@ export const adminNavNamespaces = ( ...(item.items ?? []).map(subItem => titleNamespace(subItem.title)), ]), ]), + ADMIN_NAV_NAMESPACE_BUDGET, ); /** diff --git a/packages/vitnode/src/views/admin/views/containers/container-dialogs.tsx b/packages/vitnode/src/views/admin/views/containers/container-dialogs.tsx index 5698c1d8a5..cdd0673fdf 100644 --- a/packages/vitnode/src/views/admin/views/containers/container-dialogs.tsx +++ b/packages/vitnode/src/views/admin/views/containers/container-dialogs.tsx @@ -22,6 +22,7 @@ import { import { Spinner } from "@/components/ui/spinner"; import type { ContentContainerFormProps } from "./container-form-content"; +import type { ContentContainerPoliciesFormProps } from "./container-policies-form"; import type { AdminContentContainer, ContentContainerMutationResult, @@ -35,6 +36,18 @@ const ContentContainerFormContent = React.lazy(async () => })), ); +const ContentContainerPoliciesFormContent = React.lazy(async () => + import("./container-policies-form").then(module => ({ + default: module.ContentContainerPoliciesFormContent, + })), +); + +const SheetFallback = () => ( +
+ +
+); + export const ContentContainerSheet = ({ formKey, onOpenChange, @@ -71,13 +84,7 @@ export const ContentContainerSheet = ({ {isEdit && !container ? null : ( - - -
- } - > + }> & { + container: AdminContentContainer | undefined; + formKey: number; + onOpenChange: (open: boolean) => void; + open: boolean; +}) => { + const t = useTranslations("core.containers.policies"); + + return ( + + + + {t("title")} + + {container?.name} + + + + {container ? ( + }> + + + ) : null} + + + ); +}; + const DeleteBody = ({ childCount, container, diff --git a/packages/vitnode/src/views/admin/views/containers/container-manager.tsx b/packages/vitnode/src/views/admin/views/containers/container-manager.tsx index df5d55f676..2a1f0fd553 100644 --- a/packages/vitnode/src/views/admin/views/containers/container-manager.tsx +++ b/packages/vitnode/src/views/admin/views/containers/container-manager.tsx @@ -56,6 +56,7 @@ import { FinePointerSensor } from "@/lib/dnd/sensors"; import { isAdminRequestError } from "@/views/admin/admin-request"; import type { ContentContainerSave } from "./container-form-content"; +import type { ContentContainerPoliciesSave } from "./container-policies-form"; import type { AdminContentContainer, ContentContainerMutationResult, @@ -64,6 +65,7 @@ import type { ContainerDropProjection } from "./container-tree"; import { ContentContainerDeleteDialog, + ContentContainerPoliciesSheet, ContentContainerSheet, } from "./container-dialogs"; import { contentContainerErrorKey } from "./container-query"; @@ -103,6 +105,7 @@ export interface ContentContainerManagerProps { }) => Promise>; onRetry: () => void; onSave: ContentContainerSave; + onSavePolicies?: ContentContainerPoliciesSave; pluginId: string; records: AdminContentContainer[] | undefined; } @@ -215,6 +218,7 @@ export const ContentContainerManager = ({ onMove, onRetry, onSave, + onSavePolicies, pluginId, records, }: ContentContainerManagerProps) => { @@ -255,6 +259,7 @@ export const ContentContainerManager = ({ const sheet = useOverlay(); const [sheetMode, setSheetMode] = React.useState<"create" | "edit">("create"); const deleting = useOverlay(); + const policies = useOverlay(); const announcementContainer = React.useSyncExternalStore( subscribeToNothing, getAnnouncementContainer, @@ -464,6 +469,12 @@ export const ContentContainerManager = ({ const move = rootContainerMove(ordered, id); if (move) commitMove(move); }, + onPolicies: + definition.policies.enabled && onSavePolicies + ? id => { + policies.show({ id }); + } + : undefined, onShift: (id, step) => { const move = shiftContainerMove(ordered, id, step); if (move) commitMove(move); @@ -659,6 +670,10 @@ export const ContentContainerManager = ({ sheet.id === null ? undefined : ordered.find(record => record.id === sheet.id); + const policiesContainer = + policies.id === null + ? undefined + : ordered.find(record => record.id === policies.id); const deletingContainer = deleting.id === null ? undefined @@ -687,6 +702,20 @@ export const ContentContainerManager = ({ rules={rules} /> + {definition.policies.enabled && onSavePolicies ? ( + + ) : null} + Promise>; + +export interface ContentContainerPoliciesFormProps { + container: AdminContentContainer; + definition: AnyContentContainerDefinition; + onSavePolicies: ContentContainerPoliciesSave; + pluginId: string; + records: readonly AdminContentContainer[]; + singular: string; +} + +type ChoiceKey = Exclude; + +const CHOICES: { [K in ChoiceKey]: readonly ContentContainerPolicy[K][] } = { + create: CONTENT_CONTAINER_POLICY_CREATE, + publish: CONTENT_CONTAINER_POLICY_MODES, + edits: CONTENT_CONTAINER_POLICY_MODES, + authorEdit: CONTENT_CONTAINER_POLICY_AUTHOR_EDIT, +}; + +const CHOICE_KEYS = ["create", "publish", "edits", "authorEdit"] as const; + +const choicesOf = (key: ChoiceKey): readonly string[] => CHOICES[key]; + +const INHERIT = "inherit"; + +type PolicyEntry = AdminContentContainerPolicyReport["contentTypes"][number]; + +const fieldId = (index: number, key: ContentContainerPolicyKey) => + `type${String(index)}_${key}`; + +const authorEditOf = ( + values: Record, + index: number, + entry: PolicyEntry, +): string => { + const value = values[fieldId(index, "authorEdit")]; + + return typeof value === "string" && value !== INHERIT + ? value + : entry.inherited.authorEdit; +}; + +const buildFormSchema = (report: AdminContentContainerPolicyReport) => { + const shape: Record = {}; + + report.contentTypes.forEach((entry, index) => { + const saved = report.overrides[entry.contentTypeId] ?? {}; + for (const key of CHOICE_KEYS) { + const values: [string, ...string[]] = [INHERIT, ...choicesOf(key)]; + shape[fieldId(index, key)] = z + .enum(values) + .default(saved[key] ?? INHERIT); + } + shape[fieldId(index, "authorEditMinutes")] = z + .number() + .int() + .min(1) + .max(CONTENT_CONTAINER_POLICY_MAX_MINUTES) + .nullable() + .default(saved.authorEditMinutes ?? null); + }); + + return z.object(shape); +}; + +const PoliciesForm = ({ + container, + onSavePolicies, + records, + report, + singular, +}: Omit & { + report: AdminContentContainerPolicyReport; +}) => { + const t = useTranslations("core.containers.policies"); + const tContainers = useTranslations("core.containers"); + const tError = useTranslations("core.global.errors"); + const tRoot = useTranslations() as unknown as ContentLabelTranslator; + const { setIsDirty, setOpen } = useDialog(); + const headingId = React.useId(); + + const formSchema = buildFormSchema(report); + + const optionLabel = (key: ChoiceKey, value: string): string => { + switch (key) { + case "authorEdit": + return t(`authorEdit.${value as ContentContainerPolicy["authorEdit"]}`); + case "create": + return t(`create.${value as ContentContainerPolicy["create"]}`); + case "edits": + case "publish": + return t(`${key}.${value as ContentContainerPolicy["publish"]}`); + } + }; + + const sourceText = ( + entry: PolicyEntry, + key: ContentContainerPolicyKey, + ): string => { + if (report.overrides[entry.contentTypeId]?.[key] !== undefined) { + return t("source.self", { name: singular }); + } + + const source: AdminContentContainerPolicySource | undefined = + entry.sources[key]; + if (!source || source.kind === "fallback") return t("source.fallback"); + if (source.kind === "container") { + const name = + records.find(record => record.id === source.containerId)?.name ?? + `#${String(source.containerId)}`; + + return t("source.container", { name }); + } + + return t(`source.${source.kind}`); + }; + + const describe = ( + entry: PolicyEntry, + key: ContentContainerPolicyKey, + ): React.ReactNode => ( + <> + {t(`${key}.desc`)} + {sourceText(entry, key)} + + ); + + const fields = report.contentTypes.flatMap((entry, index) => [ + ...CHOICE_KEYS.map(key => ({ + component: (props: ItemAutoFormComponentProps) => ( + ({ + label: optionLabel(key, value), + value, + })), + ]} + /> + ), + id: fieldId(index, key), + })), + { + component: (props: ItemAutoFormComponentProps) => ( + + ), + hidden: (values: Record) => + authorEditOf(values, index, entry) !== "window", + id: fieldId(index, "authorEditMinutes"), + }, + ]); + + const onSubmit: AutoFormOnSubmit = async values => { + const read = values as Record; + const raw = Object.fromEntries( + report.contentTypes.flatMap((entry, index) => { + const policy: Record = {}; + for (const key of CHOICE_KEYS) { + const value = read[fieldId(index, key)]; + if (typeof value === "string" && value !== INHERIT) { + policy[key] = value; + } + } + const minutes = + authorEditOf(read, index, entry) === "window" + ? read[fieldId(index, "authorEditMinutes")] + : report.overrides[entry.contentTypeId]?.authorEditMinutes; + if (typeof minutes === "number") policy.authorEditMinutes = minutes; + + return Object.keys(policy).length > 0 + ? [[entry.contentTypeId, policy]] + : []; + }), + ); + + const parsed = zodContentContainerPolicyOverrides.safeParse(raw); + const result = parsed.success + ? await onSavePolicies({ id: container.id, overrides: parsed.data }) + : { error: { code: "invalidPolicy" as const, status: 400 } }; + + if ("error" in result) { + toast.error(tError("title"), { + description: tContainers(contentContainerErrorKey(result.error)), + }); + + return; + } + + toast.success(t("success"), { + description: t("successDesc", { name: container.name }), + }); + setIsDirty?.(false); + setOpen?.(false); + }; + + return ( + ( + <> +
+

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

+ + {report.contentTypes.length === 0 ? ( +

+ {t("empty")} +

+ ) : null} + + {report.contentTypes.map((entry, index) => { + const id = `${headingId}-${String(index)}`; + + return ( +
+

+ { + contentNouns( + { id: entry.contentTypeId }, + entry.pluginId, + tRoot, + ).title + } +

+ {CHOICE_KEYS.map(key => ( + + {rendered[fieldId(index, key)]} + + ))} + {rendered[fieldId(index, "authorEditMinutes")]} +
+ ); + })} +
+ + + )} + onSubmit={onSubmit} + /> + ); +}; + +export const ContentContainerPoliciesFormContent = ({ + definition, + pluginId, + ...props +}: ContentContainerPoliciesFormProps) => { + const t = useTranslations("core.containers.policies"); + const query = useQuery( + contentContainerPoliciesQuery({ + definitionId: definition.id, + id: props.container.id, + target: contentContainerApiTarget({ definition, pluginId }), + }), + ); + + if (query.isFetching) { + return ( +
+ +
+ ); + } + + if (!query.data) { + return ( +
+

+ {t("loadError")} +

+ +
+ ); + } + + return ( + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/containers/container-query.ts b/packages/vitnode/src/views/admin/views/containers/container-query.ts index fa50f7c862..ec94d18c8c 100644 --- a/packages/vitnode/src/views/admin/views/containers/container-query.ts +++ b/packages/vitnode/src/views/admin/views/containers/container-query.ts @@ -10,7 +10,10 @@ import { adminQueryRoot } from "@/views/admin/table/query"; import type { ContentContainerApiTarget } from "./container-request"; -import { fetchContentContainers } from "./container-request"; +import { + fetchContentContainerPolicies, + fetchContentContainers, +} from "./container-request"; export const contentContainerQueryRoot = (definitionId: string) => [...adminQueryRoot("containers"), definitionId] as const; @@ -34,6 +37,25 @@ export const contentContainerListQuery = ({ staleTime: RECORD_STALE_TIME, }); +export const contentContainerPoliciesQuery = ({ + definitionId, + id, + target, +}: { + definitionId: string; + id: number; + target: ContentContainerApiTarget; +}) => + queryOptions({ + queryFn: async ({ signal }) => + await fetchContentContainerPolicies(target, id, { signal }), + queryKey: [...contentContainerQueryRoot(definitionId), "policies", id], + refetchOnWindowFocus: false, + retry: (failureCount, error) => + !(isAdminRequestError(error) && error.status < 500) && failureCount < 2, + staleTime: 0, + }); + export const invalidateContentContainers = async ( queryClient: QueryClient, definitionId: string, diff --git a/packages/vitnode/src/views/admin/views/containers/container-request.ts b/packages/vitnode/src/views/admin/views/containers/container-request.ts index d849ae4b8c..12c718ed3d 100644 --- a/packages/vitnode/src/views/admin/views/containers/container-request.ts +++ b/packages/vitnode/src/views/admin/views/containers/container-request.ts @@ -1,8 +1,13 @@ import { z } from "zod"; +import type { ContentContainerPolicyOverrides } from "@/content/containers/policies"; import type { ContentContainerErrorCode } from "@/content/containers/types"; import { CONTENT_CONTAINER_ERROR_CODES } from "@/content/containers/const"; +import { + zodContentContainerPolicyOverrides, + zodEffectiveContentContainerPolicy, +} from "@/content/containers/policies"; import { rawFetcher } from "@/tanstack/fetcher"; import { AdminRequestError } from "@/views/admin/admin-request"; @@ -96,7 +101,7 @@ const send = async ( target: ContentContainerApiTarget, request: { body?: unknown; - method: "delete" | "patch" | "post"; + method: "delete" | "patch" | "post" | "put"; params?: Record; path: string; }, @@ -162,3 +167,72 @@ export const deleteContentContainer = async ( params: { id }, path: "/{id}", }); + +const policySourceSchema = z.union([ + z.object({ containerId: z.number(), kind: z.literal("container") }), + z.object({ kind: z.enum(["contentType", "default", "fallback"]) }), +]); + +const policyReportSchema = z.object({ + contentTypes: z.array( + z.object({ + contentTypeId: z.string(), + effective: zodEffectiveContentContainerPolicy, + inherited: zodEffectiveContentContainerPolicy, + pluginId: z.string(), + sources: z.record(z.string(), policySourceSchema), + }), + ), + id: z.number(), + overrides: zodContentContainerPolicyOverrides, +}); + +export type AdminContentContainerPolicyReport = z.infer< + typeof policyReportSchema +>; + +export type AdminContentContainerPolicySource = z.infer< + typeof policySourceSchema +>; + +export const fetchContentContainerPolicies = async ( + target: ContentContainerApiTarget, + id: number, + { signal }: { signal?: AbortSignal } = {}, +): Promise => { + const description = `the publishing rules of container ${String(id)} in ${describe(target)}`; + const response = await rawFetcher({ + ...requestArgs(target), + method: "get", + options: { signal }, + params: { id }, + path: "/{id}/policies", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, description); + } + + const parsed = policyReportSchema.safeParse(await response.json()); + if (!parsed.success) { + throw new AdminRequestError( + response.status, + description, + "the API returned a response this collection does not describe", + ); + } + + return parsed.data; +}; + +export const updateContentContainerPolicies = async ( + target: ContentContainerApiTarget, + id: number, + overrides: ContentContainerPolicyOverrides, +): Promise> => + await send(target, { + body: { overrides }, + method: "put", + params: { id }, + path: "/{id}/policies", + }); diff --git a/packages/vitnode/src/views/admin/views/containers/container-row.tsx b/packages/vitnode/src/views/admin/views/containers/container-row.tsx index 7c03c67276..6c74f20ec5 100644 --- a/packages/vitnode/src/views/admin/views/containers/container-row.tsx +++ b/packages/vitnode/src/views/admin/views/containers/container-row.tsx @@ -12,6 +12,7 @@ import { MoreHorizontalIcon, PencilIcon, PlusIcon, + ShieldCheckIcon, Trash2Icon, } from "lucide-react"; import { useTranslations } from "use-intl"; @@ -46,6 +47,8 @@ export interface ContainerRowActions { onEdit: (id: number) => void; onMoveInside: (id: number, parentId: number) => void; onMoveToRoot: (id: number) => void; + /** Opens the publishing rules; absent when the collection has no policies. */ + onPolicies?: (id: number) => void; onShift: (id: number, step: -1 | 1) => void; onToggle: (id: number) => void; } @@ -88,7 +91,9 @@ const ContainerRowMenu = ({ permissions: ContainerRowPermissions; }) => { const t = useTranslations("core.containers.list"); + const tPolicies = useTranslations("core.containers.policies"); const { id, name } = container; + const { onPolicies } = actions; const showMoves = permissions.canMove && (moves.canMoveUp || @@ -122,6 +127,16 @@ const ContainerRowMenu = ({ {t("edit")} ) : null} + {permissions.canEdit && onPolicies ? ( + { + onPolicies(id); + }} + > + + {tPolicies("action")} + + ) : null} {moves.canAddChild ? ( { diff --git a/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts b/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts index c709c210e6..49020ffff6 100644 --- a/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts +++ b/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts @@ -1,4 +1,3 @@ -import { requestHandler } from "@tanstack/react-start/server"; import { afterEach, aroundEach, @@ -9,6 +8,8 @@ import { vi, } from "vitest"; +import { runInStartRequest } from "@/tests/start-request"; + import { createContentInBrowser, editContentInBrowser, @@ -66,17 +67,9 @@ const uniqueConflict = JSON.stringify({ itemId: 7, }); -const insideStartRequest = async ( - runTest: () => Promise, -): Promise => { - await requestHandler(async () => { - await runTest(); - - return new Response(null, { status: 204 }); - })(new Request(`${API_ORIGIN}/admin/content/posts`), undefined); -}; - -aroundEach(insideStartRequest); +aroundEach(async runTest => { + await runInStartRequest(`${API_ORIGIN}/admin/content/posts`, runTest); +}); beforeEach(() => { respond = () => new Response(null, { status: 200 }); diff --git a/packages/vitnode/src/views/moderation/approval-mutations.ts b/packages/vitnode/src/views/moderation/approval-mutations.ts new file mode 100644 index 0000000000..02017030e0 --- /dev/null +++ b/packages/vitnode/src/views/moderation/approval-mutations.ts @@ -0,0 +1,88 @@ +import { CONFIG_PLUGIN } from "@/config"; +import { fetcherClient } from "@/lib/fetcher-client"; + +const DECISION_FAILURES = [ + "decided", + "forbidden", + "gone", + "notFound", + "stale", +] as const; + +export type ApprovalFailure = + | "rate_limited" + | "unknown" + | (typeof DECISION_FAILURES)[number]; + +export type ApprovalResult = + | { data: { id: number; status: string }; ok: true } + | { failure: ApprovalFailure; ok: false }; + +const isDecisionFailure = ( + value: unknown, +): value is (typeof DECISION_FAILURES)[number] => + DECISION_FAILURES.some(failure => failure === value); + +const failureOf = async (response: Response): Promise => { + if (response.status === 429) return "rate_limited"; + const body = (await response.json().catch(() => null)) as null | { + error?: unknown; + }; + if (isDecisionFailure(body?.error)) return body.error; + if (response.status === 403) return "forbidden"; + if (response.status === 404) return "notFound"; + + return "unknown"; +}; + +const settle = async ( + request: () => Promise, +): Promise => { + try { + const response = await request(); + if (response.status !== 200) { + return { failure: await failureOf(response), ok: false }; + } + + return { + data: (await response.json()) as { id: number; status: string }, + ok: true, + }; + } catch { + return { failure: "unknown", ok: false }; + } +}; + +const options = { credentials: "include" } as const; + +export const approveSubmission = async ({ id }: { id: number }) => + await settle( + async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { id } }, + method: "post", + module: "moderation", + options, + path: "/approvals/{id}/approve", + }), + ); + +export const rejectSubmission = async ({ + id, + reason, +}: { + id: number; + reason: null | string; +}) => + await settle( + async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: { reason }, params: { id } }, + method: "post", + module: "moderation", + options, + path: "/approvals/{id}/reject", + }), + ); diff --git a/packages/vitnode/src/views/moderation/modcp/approvals/approval-content.tsx b/packages/vitnode/src/views/moderation/modcp/approvals/approval-content.tsx new file mode 100644 index 0000000000..5fbb052e84 --- /dev/null +++ b/packages/vitnode/src/views/moderation/modcp/approvals/approval-content.tsx @@ -0,0 +1,457 @@ +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { cn } from "cn"; +import { CheckIcon, ExternalLinkIcon, FolderIcon, XIcon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; + +import type { ApprovalResult } from "../../approval-mutations"; +import type { ModcpApprovalDetail, ModcpApprovalStatus } from "../modcp-query"; + +import { approveSubmission } from "../../approval-mutations"; +import { invalidateModcp, modcpApprovalQueryOptions } from "../modcp-query"; +import { ModcpPluginMessages } from "../plugin-messages"; +import { Person, PersonAvatar, PersonName } from "../report-tone"; +import { useApprovalFailure } from "./approval-failure"; +import { ComparedFields, isStaleEdit, ProposedFields } from "./approval-fields"; +import { approvalTypeLabelKey, useApprovalLabels } from "./approval-labels"; +import { ApprovalKindChip, ApprovalStatusChip } from "./approval-tone"; + +const ApprovalRejectForm = React.lazy( + async () => await import("./approval-reject-form"), +); + +const QUEUE_PATH = "/modcp/approvals"; + +const DECISION_ALERT: Record< + Exclude, + "default" | "destructive" | "success" +> = { + approved: "success", + cancelled: "default", + rejected: "destructive", + superseded: "default", +}; + +export const ModcpApprovalContent = ({ id }: { id: number }) => { + const { data } = useSuspenseQuery(modcpApprovalQueryOptions(id)); + + return ( + + + + ); +}; + +const Section = ({ + children, + title, +}: { + children: React.ReactNode; + title: string; +}) => { + const headingId = React.useId(); + + return ( +
+

+ {title} +

+ {children} +
+ ); +}; + +const containerPathOf = (container: ModcpApprovalDetail["container"]) => + container ? [...container.path, container.name].join(" / ") : null; + +const DecisionNotice = ({ data }: { data: ModcpApprovalDetail }) => { + const t = useTranslations("core.moderation.modcp.approvals.detail.decided"); + + if (data.status === "pending") return null; + + return ( + + {t(`${data.status}.title`)} + + {t(`${data.status}.desc`)} + {data.reviewer || data.decidedAt ? ( + + {data.reviewer ? ( + + ) : null} + {data.reviewer && data.decidedAt ? ( + · + ) : null} + {data.decidedAt ? : null} + + ) : null} + {data.reason ? ( + + {t("reason", { reason: data.reason })} + + ) : null} + + + ); +}; + +const PendingWarnings = ({ data }: { data: ModcpApprovalDetail }) => { + const t = useTranslations("core.moderation.modcp.approvals.detail.warnings"); + + if (data.status !== "pending") return null; + + if (!data.record.exists) { + return ( + + {t("gone_title")} + + {t("gone_desc")} + + + ); + } + + if (data.kind === "edit" && isStaleEdit(data)) { + return ( + + {t("stale_title")} + + {t("stale_desc")} + + + ); + } + + return null; +}; + +const History = ({ data }: { data: ModcpApprovalDetail }) => { + const t = useTranslations("core.moderation.modcp.approvals"); + const entries = data.history.filter(entry => entry.id !== data.id); + + if (entries.length === 0) { + return ( +

+ {t("detail.history_empty")} +

+ ); + } + + return ( +
    + {entries.map(entry => ( +
  • + + {entry.author ? ( + + ) : null} + + + + #{entry.id} + + + + + + {entry.author ? ( + <> + + · + + ) : null} + + + {entry.reason ? ( + + {t("reason_preview", { reason: entry.reason })} + + ) : null} + + +
  • + ))} +
+ ); +}; + +const FieldRow = ({ + children, + label, +}: { + children: React.ReactNode; + label: string; +}) => ( +
+
{label}
+
{children}
+
+); + +const Decision = ({ data }: { data: ModcpApprovalDetail }) => { + const t = useTranslations("core.moderation.modcp.approvals"); + const fail = useApprovalFailure(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [approving, setApproving] = React.useState(false); + const [rejecting, setRejecting] = React.useState(false); + + const settle = async ( + result: ApprovalResult, + decision: "approve" | "reject", + ) => { + if (fail(result)) { + if ( + !result.ok && + (result.failure === "forbidden" || result.failure === "notFound") + ) { + await navigate({ to: QUEUE_PATH }); + } + await invalidateModcp(queryClient); + + return; + } + + toast.success(t(`${decision}.success`), { + description: t(`${decision}.success_desc`, { + title: data.title ?? t("untitled"), + }), + }); + setRejecting(false); + await navigate({ to: QUEUE_PATH }); + await invalidateModcp(queryClient); + }; + + return ( +
+ + + + + + + + {t("reject.title")} + + + {t("reject.desc")} + + + + + +
+ } + > + { + await settle(result, "reject"); + }} + /> +
+ + +
+ ); +}; + +const Sidebar = ({ + data, + typeLabel, +}: { + data: ModcpApprovalDetail; + typeLabel: string; +}) => { + const t = useTranslations("core.moderation.modcp.approvals.detail"); + const containerPath = containerPathOf(data.container); + + return ( +
+ {data.status === "pending" ? : null} + +
+

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

+
+ + {data.author ? ( + + ) : ( + + {t("details.deleted_user")} + + )} + + {typeLabel} + {containerPath ? ( + + + + {containerPath} + + + ) : null} + + + + + + + {data.reviewer ? ( + + + + ) : null} +
+
+ +
+
{t("details.submitted")}
+
+ +
+ {data.decidedAt ? ( + <> +
{t("details.decided")}
+
+ +
+ + ) : null} +
+ + {data.record.exists && data.record.url ? ( + + + {t(data.record.public ? "view_live" : "view_draft")} + {t("new_tab")} + + ) : null} +
+ ); +}; + +const ApprovalDetailView = ({ id }: { id: number }) => { + const t = useTranslations("core.moderation.modcp.approvals"); + const { data } = useSuspenseQuery(modcpApprovalQueryOptions(id)); + const labels = useApprovalLabels(); + const typeLabel = labels.type(data); + const containerPath = containerPathOf(data.container); + + return ( +
+
+
+ + +
+

+ {data.title ?? t("untitled")} +

+

+ {containerPath + ? t("detail.subtitle_in", { + container: containerPath, + type: typeLabel, + }) + : t("detail.subtitle", { type: typeLabel })} +

+
+ +
+
+ + + + {data.kind === "edit" && data.current ? ( +
+ +
+ ) : ( +
+ +
+ )} + +
+ +
+
+ + +
+
+ ); +}; diff --git a/packages/vitnode/src/views/moderation/modcp/approvals/approval-failure.ts b/packages/vitnode/src/views/moderation/modcp/approvals/approval-failure.ts new file mode 100644 index 0000000000..fef23a88f7 --- /dev/null +++ b/packages/vitnode/src/views/moderation/modcp/approvals/approval-failure.ts @@ -0,0 +1,18 @@ +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { ApprovalResult } from "../../approval-mutations"; + +/** Shows the toast for a failed decision; `true` when there was one. */ +export const useApprovalFailure = () => { + const t = useTranslations("core.moderation.modcp.approvals.errors"); + + return (result: ApprovalResult): boolean => { + if (result.ok) return false; + toast.error(t(`${result.failure}.title`), { + description: t(`${result.failure}.desc`), + }); + + return true; + }; +}; diff --git a/packages/vitnode/src/views/moderation/modcp/approvals/approval-fields.tsx b/packages/vitnode/src/views/moderation/modcp/approvals/approval-fields.tsx new file mode 100644 index 0000000000..12f72acf17 --- /dev/null +++ b/packages/vitnode/src/views/moderation/modcp/approvals/approval-fields.tsx @@ -0,0 +1,277 @@ +import { cn } from "cn"; +import { ChevronDownIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { RichTextContent } from "@/components/rich-text"; +import { isRichTextDocument } from "@/content/rich-text/document"; + +import type { ModcpApprovalDetail } from "../modcp-query"; + +import { Chip } from "../report-tone"; +import { useApprovalLabels } from "./approval-labels"; + +type Values = Record; +type Field = ModcpApprovalDetail["fields"][number]; + +const TEXT_KINDS = new Set(["slug", "text", "textarea"]); +const MAX_COMPACT_LENGTH = 400; + +const isEmptyValue = (value: unknown): boolean => + value === null || + value === undefined || + value === "" || + (Array.isArray(value) && value.length === 0); + +const compactValue = (value: unknown): string => { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "bigint") { + return String(value); + } + if ( + Array.isArray(value) && + value.every(item => ["number", "string"].includes(typeof item)) + ) { + return value.join(", "); + } + const json = JSON.stringify(value) ?? ""; + + return json.length > MAX_COMPACT_LENGTH + ? `${json.slice(0, MAX_COMPACT_LENGTH)}…` + : json; +}; + +/** Field values are JSON from the API, so their JSON is what "the same" means. */ +export const sameValue = (a: unknown, b: unknown): boolean => + JSON.stringify(a ?? null) === JSON.stringify(b ?? null); + +export const ApprovalFieldValue = ({ + kind, + value, +}: { + kind: string; + value: unknown; +}) => { + const t = useTranslations("core.moderation.modcp.approvals.detail"); + + if (isEmptyValue(value)) { + return

{t("empty")}

; + } + + if (kind === "richText") { + return isRichTextDocument(value) ? ( + + ) : ( +

{t("empty")}

+ ); + } + + if (typeof value === "boolean") { + return ( +

+ {value ? t("yes") : t("no")} +

+ ); + } + + return ( +

+ {compactValue(value)} +

+ ); +}; + +const FieldLabel = ({ + changed, + id, + label, +}: { + changed?: boolean; + id: string; + label: string; +}) => { + const t = useTranslations("core.moderation.modcp.approvals.detail"); + + return ( +
+

+ {label} +

+ {changed ? {t("changed")} : null} +
+ ); +}; + +const Panel = ({ + children, + highlight, + label, +}: { + children: React.ReactNode; + highlight?: boolean; + label: string; +}) => ( +
+ {label} + {children} +
+); + +interface FieldsProps { + data: Pick; +} + +/** A new record: every proposed value, in the definition's field order. */ +export const ProposedFields = ({ + data, + values, +}: FieldsProps & { values: Values }) => { + const labels = useApprovalLabels(); + const baseId = React.useId(); + const fields = data.fields.filter(field => field.name in values); + + return ( +
+ {fields.map(field => ( +
+ + +
+ ))} +
+ ); +}; + +const ComparedField = ({ + current, + data, + field, + proposed, +}: FieldsProps & { + current: unknown; + field: Field; + proposed: unknown; +}) => { + const t = useTranslations("core.moderation.modcp.approvals.detail"); + const labels = useApprovalLabels(); + const id = React.useId(); + const changed = !sameValue(current, proposed); + + return ( +
+ + {changed ? ( +
+ + + + + + +
+ ) : ( + + )} +
+ ); +}; + +/** An edit: changed fields side by side, the untouched ones folded away. */ +export const ComparedFields = ({ + current, + data, + values, +}: FieldsProps & { current: Values; values: Values }) => { + const t = useTranslations("core.moderation.modcp.approvals.detail"); + const fields = data.fields.filter( + field => field.name in values || field.name in current, + ); + const proposedOf = (field: Field) => + field.name in values ? values[field.name] : current[field.name]; + const changed = fields.filter( + field => !sameValue(current[field.name], proposedOf(field)), + ); + const unchanged = fields.filter(field => !changed.includes(field)); + + return ( +
+ {changed.length === 0 ? ( +

+ {t("no_changes")} +

+ ) : ( + changed.map(field => ( + + )) + )} + + {unchanged.length > 0 ? ( +
+ + {t("unchanged", { count: unchanged.length })} + + +
+ {unchanged.map(field => ( + + ))} +
+
+ ) : null} +
+ ); +}; + +/** True when the live record moved on after the author started editing. */ +export const isStaleEdit = ( + data: Pick, +): boolean => { + if (!data.baseValues || !data.current) return false; + const { baseValues, current } = data; + + return Object.keys(data.values).some( + name => !sameValue(baseValues[name], current[name]), + ); +}; diff --git a/packages/vitnode/src/views/moderation/modcp/approvals/approval-labels.ts b/packages/vitnode/src/views/moderation/modcp/approvals/approval-labels.ts new file mode 100644 index 0000000000..d74b67e512 --- /dev/null +++ b/packages/vitnode/src/views/moderation/modcp/approvals/approval-labels.ts @@ -0,0 +1,40 @@ +import { useTranslations } from "use-intl"; + +import { + contentEntityKey, + contentTypeName, + humanizeFieldName, +} from "@/content/admin/labels"; + +const baseKey = (pluginId: string, contentTypeId: string) => + `${pluginId}.content.${contentEntityKey(contentTypeId)}`; + +/** The plugin label key whose namespace also holds the type's field names. */ +export const approvalTypeLabelKey = (subject: { + contentTypeId: string; + pluginId: string; +}): string => `${baseKey(subject.pluginId, subject.contentTypeId)}.label`; + +/** Translates a content type's noun and its field names, falling back to readable ids. */ +export const useApprovalLabels = () => { + const tRoot = useTranslations(); + const has = (key: string) => tRoot.has(key as never); + + return { + field: ( + subject: { contentTypeId: string; pluginId: string }, + name: string, + ): string => { + const key = `${baseKey(subject.pluginId, subject.contentTypeId)}.fields.${name}`; + + return has(key) ? tRoot(key as never) : humanizeFieldName(name); + }, + type: (subject: { contentTypeId: string; pluginId: string }): string => { + const key = approvalTypeLabelKey(subject); + + return has(key) + ? tRoot(key as never, { count: 1 } as never) + : contentTypeName(subject.contentTypeId); + }, + }; +}; diff --git a/packages/vitnode/src/views/moderation/modcp/approvals/approval-reject-form.tsx b/packages/vitnode/src/views/moderation/modcp/approvals/approval-reject-form.tsx new file mode 100644 index 0000000000..6a45b9918e --- /dev/null +++ b/packages/vitnode/src/views/moderation/modcp/approvals/approval-reject-form.tsx @@ -0,0 +1,57 @@ +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormTextarea } from "@/components/form/fields/textarea"; + +import type { ApprovalResult } from "../../approval-mutations"; + +import { rejectSubmission } from "../../approval-mutations"; + +const REASON_MAX_LENGTH = 2000; + +const ApprovalRejectForm = ({ + id, + onSettled, +}: { + id: number; + onSettled: (result: ApprovalResult) => Promise; +}) => { + const t = useTranslations("core.moderation.modcp.approvals.reject"); + const formSchema = z.object({ + reason: z.string().trim().max(REASON_MAX_LENGTH).optional(), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const reason = values.reason?.trim() ?? ""; + await onSettled( + await rejectSubmission({ id, reason: reason === "" ? null : reason }), + ); + }; + + return ( + ( + + ), + id: "reason", + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("submit"), variant: "destructive" }} + /> + ); +}; + +export default ApprovalRejectForm; diff --git a/packages/vitnode/src/views/moderation/modcp/approvals/approval-tone.tsx b/packages/vitnode/src/views/moderation/modcp/approvals/approval-tone.tsx new file mode 100644 index 0000000000..5274b99fe1 --- /dev/null +++ b/packages/vitnode/src/views/moderation/modcp/approvals/approval-tone.tsx @@ -0,0 +1,42 @@ +import { FilePlusIcon, PencilIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import type { ModcpApprovalItem, ModcpApprovalStatus } from "../modcp-query"; +import type { ToneName } from "../report-tone"; + +import { Chip } from "../report-tone"; + +export const APPROVAL_STATUS_TONE: Record = { + approved: "success", + cancelled: "muted", + pending: "warning", + rejected: "destructive", + superseded: "muted", +}; + +export const ApprovalKindChip = ({ + kind, +}: { + kind: ModcpApprovalItem["kind"]; +}) => { + const t = useTranslations("core.moderation.modcp.approvals.kinds"); + + return ( + + {t(kind)} + + ); +}; + +export const ApprovalStatusChip = ({ + status, +}: { + status: ModcpApprovalStatus; +}) => { + const t = useTranslations("core.moderation.modcp.approvals.statuses"); + + return {t(status)}; +}; diff --git a/packages/vitnode/src/views/moderation/modcp/approvals/approvals-content.tsx b/packages/vitnode/src/views/moderation/modcp/approvals/approvals-content.tsx new file mode 100644 index 0000000000..6337a22972 --- /dev/null +++ b/packages/vitnode/src/views/moderation/modcp/approvals/approvals-content.tsx @@ -0,0 +1,312 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { cn } from "cn"; +import { ChevronRightIcon, ClipboardCheckIcon, FolderIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { FiltersDataTable } from "@/components/table/filters"; +import { useDataTableUrl } from "@/components/table/navigation"; +import { PaginationDataTable } from "@/components/table/pagination"; +import { withTableFilter } from "@/components/table/url-state"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; + +import type { + ModcpApprovalItem, + ModcpApprovalsFilter, + ModcpApprovalStatus, +} from "../modcp-query"; + +import { + MODCP_APPROVAL_STATUSES, + modcpApprovalFiltersQueryOptions, + modcpApprovalsQueryOptions, +} from "../modcp-query"; +import { ModcpPluginMessages } from "../plugin-messages"; +import { PersonAvatar, PersonName } from "../report-tone"; +import { approvalTypeLabelKey, useApprovalLabels } from "./approval-labels"; +import { ApprovalKindChip, ApprovalStatusChip } from "./approval-tone"; + +const StatusTabs = ({ status }: { status: ModcpApprovalStatus }) => { + const t = useTranslations("core.moderation.modcp.approvals"); + const { navigate, searchParams } = useDataTableUrl(); + + return ( + + ); +}; + +const QueueFilters = () => { + const t = useTranslations("core.moderation.modcp.approvals.filters"); + const labels = useApprovalLabels(); + const { data } = useSuspenseQuery(modcpApprovalFiltersQueryOptions()); + + if (data.contentTypes.length < 2 && data.containers.length < 2) return null; + + return ( +
+ 1 + ? [ + { + id: "type", + label: t("type"), + options: data.contentTypes.map(type => { + const label = labels.type({ + contentTypeId: type.id, + pluginId: type.pluginId, + }); + + return { keywords: [label], label, value: type.id }; + }), + single: { allLabel: t("all_types") }, + }, + ] + : []), + ...(data.containers.length > 1 + ? [ + { + id: "container", + label: t("container"), + options: data.containers.map(container => ({ + keywords: [container.name], + label: `${"– ".repeat(container.depth)}${container.name}`, + value: String(container.id), + })), + single: { allLabel: t("all_containers") }, + }, + ] + : []), + ]} + /> +
+ ); +}; + +const QueueRow = ({ item }: { item: ModcpApprovalItem }) => { + const t = useTranslations("core.moderation.modcp.approvals"); + const labels = useApprovalLabels(); + const typeLabel = labels.type(item); + + return ( + + {item.author ? ( + + ) : ( + + )} + + + + + + {typeLabel} + + {item.status === "pending" ? null : ( + + )} + + + + {item.title ?? t("untitled")} + + + + {item.author ? ( + + ) : ( + {t("deleted_user")} + )} + {item.container ? ( + <> + · + + + + {[...item.container.path, item.container.name].join(" / ")} + + + + ) : null} + · + + + + {item.status === "rejected" && item.reason ? ( + + {t("reason_preview", { reason: item.reason })} + + ) : null} + + + + + ); +}; + +const Queue = ({ filter }: { filter: ModcpApprovalsFilter }) => { + const t = useTranslations("core.moderation.modcp.approvals"); + const { data } = useSuspenseQuery(modcpApprovalsQueryOptions(filter)); + const { isPending } = useDataTableUrl(); + const totalPages = Math.max(1, Math.ceil(data.total / data.pageSize)); + const isFiltered = + filter.contentTypeId !== undefined || filter.containerId !== undefined; + + return ( + <> + {data.items.length === 0 ? ( + + + + + + + {t( + filter.status === "pending" && !isFiltered + ? "empty.pending_title" + : "empty.title", + )} + + + {t( + filter.status === "pending" && !isFiltered + ? "empty.pending_desc" + : "empty.desc", + )} + + + + ) : ( +
    + {data.items.map(item => ( +
  • + +
  • + ))} +
+ )} + + 1, + pageSize: data.pageSize, + startCursor: null, + totalCount: data.total, + totalPages, + }} + /> + + ); +}; + +export const ModcpApprovalsContent = ({ + description, + filter, + title, +}: { + description: string; + filter: ModcpApprovalsFilter; + title: string; +}) => { + const { data } = useSuspenseQuery(modcpApprovalFiltersQueryOptions()); + + return ( + + approvalTypeLabelKey({ + contentTypeId: type.id, + pluginId: type.pluginId, + }), + )} + > +
+
+

+ {title} +

+

+ {description} +

+
+ +
+ + +
+ + +
+
+
+
+ ); +}; diff --git a/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx b/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx index 2e3c2d596d..fc9abbae15 100644 --- a/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx +++ b/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx @@ -1,14 +1,17 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { cn } from "cn"; -import { FlagIcon } from "lucide-react"; +import { ClipboardCheckIcon, FlagIcon } from "lucide-react"; import { useTranslations } from "use-intl"; import { SideNavCard, SideNavLink, SideNavList } from "@/components/side-nav"; -import { modcpReportsQueryOptions } from "./modcp-query"; +import { + modcpApprovalsQueryOptions, + modcpReportsQueryOptions, +} from "./modcp-query"; -export type ModcpNavKey = "reports"; +export type ModcpNavKey = "approvals" | "reports"; interface ModcpNavItem { href: string; @@ -18,6 +21,7 @@ interface ModcpNavItem { export const MODCP_NAV_ITEMS: readonly ModcpNavItem[] = [ { href: "/modcp/reports", icon: FlagIcon, key: "reports" }, + { href: "/modcp/approvals", icon: ClipboardCheckIcon, key: "approvals" }, ]; const OPEN_REPORTS_FILTER = { @@ -27,15 +31,24 @@ const OPEN_REPORTS_FILTER = { status: "open", } as const; +const PENDING_APPROVALS_FILTER = { + page: 1, + pageSize: 1, + status: "pending", +} as const; + export const isModcpNavItemActive = ( item: ModcpNavItem, pathname: string, ): boolean => pathname === item.href || pathname.startsWith(`${item.href}/`); const useNavCounts = (): Partial> => { - const { data } = useQuery(modcpReportsQueryOptions(OPEN_REPORTS_FILTER)); + const reports = useQuery(modcpReportsQueryOptions(OPEN_REPORTS_FILTER)); + const approvals = useQuery( + modcpApprovalsQueryOptions(PENDING_APPROVALS_FILTER), + ); - return { reports: data?.total }; + return { approvals: approvals.data?.total, reports: reports.data?.total }; }; const ModcpNavChip = ({ diff --git a/packages/vitnode/src/views/moderation/modcp/modcp-query.ts b/packages/vitnode/src/views/moderation/modcp/modcp-query.ts index 43efb91edc..82051b4377 100644 --- a/packages/vitnode/src/views/moderation/modcp/modcp-query.ts +++ b/packages/vitnode/src/views/moderation/modcp/modcp-query.ts @@ -124,5 +124,99 @@ export const modcpAssigneesQueryOptions = (id: number) => staleTime: 30_000, }); +export const MODCP_APPROVAL_STATUSES = [ + "pending", + "approved", + "rejected", + "superseded", + "cancelled", +] as const; + +export type ModcpApprovalStatus = (typeof MODCP_APPROVAL_STATUSES)[number]; + +export interface ModcpApprovalsFilter { + containerId?: number; + contentTypeId?: string; + page: number; + pageSize: number; + status: ModcpApprovalStatus; +} + +const fetchApprovals = async (filter: ModcpApprovalsFilter) => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: filter }, + method: "get", + module: "moderation", + path: "/approvals", + }); + if (!response.ok) throw new ModcpRequestError(response.status, "approvals"); + + return await response.json(); +}; + +export type ModcpApprovalsPage = Awaited>; + +export type ModcpApprovalItem = ModcpApprovalsPage["items"][number]; + +export const modcpApprovalsQueryOptions = (filter: ModcpApprovalsFilter) => + queryOptions({ + queryFn: async () => await fetchApprovals(filter), + queryKey: [...MODCP_ROOT, "approvals", filter], + retry: false, + staleTime: 10_000, + }); + +const fetchApprovalFilters = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "moderation", + path: "/approvals/filters", + }); + if (!response.ok) { + throw new ModcpRequestError(response.status, "approval filters"); + } + + return await response.json(); +}; + +export type ModcpApprovalFilters = Awaited< + ReturnType +>; + +export const modcpApprovalFiltersQueryOptions = () => + queryOptions({ + queryFn: fetchApprovalFilters, + queryKey: [...MODCP_ROOT, "approvals", "filters"], + retry: false, + staleTime: 60_000, + }); + +const fetchApproval = async (id: number) => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { id } }, + method: "get", + module: "moderation", + path: "/approvals/{id}", + }); + if (!response.ok) { + throw new ModcpRequestError(response.status, "a submission"); + } + + return await response.json(); +}; + +export type ModcpApprovalDetail = Awaited>; + +export const modcpApprovalQueryOptions = (id: number) => + queryOptions({ + queryFn: async () => await fetchApproval(id), + queryKey: [...MODCP_ROOT, "approval", id], + retry: false, + staleTime: 0, + }); + export const invalidateModcp = async (queryClient: QueryClient) => await queryClient.invalidateQueries({ queryKey: MODCP_ROOT }); diff --git a/packages/vitnode/src/views/search/registry.tsx b/packages/vitnode/src/views/search/registry.tsx index 84dd16b3f1..2e2823a504 100644 --- a/packages/vitnode/src/views/search/registry.tsx +++ b/packages/vitnode/src/views/search/registry.tsx @@ -1,8 +1,20 @@ -import { FileTextIcon, type LucideIcon } from "lucide-react"; +import { + FileTextIcon, + type LucideIcon, + MessageSquareIcon, + MessagesSquareIcon, +} from "lucide-react"; -export type SearchTypeLabelKey = "types.blog_post" | "types.unknown"; +export type SearchTypeLabelKey = + | "types.blog_post" + | "types.forum_comment" + | "types.forum_topic" + | "types.unknown"; + +export type SearchActivityKey = "activity.commented" | "activity.posted_in"; export interface SearchTypeRenderer { + activityKey?: SearchActivityKey; icon: LucideIcon; labelKey: SearchTypeLabelKey; } @@ -12,6 +24,16 @@ export interface SearchTypeRenderer { // indexes a new type never breaks the feed. const renderers: Record = { "blog.post": { icon: FileTextIcon, labelKey: "types.blog_post" }, + "forum.comment": { + activityKey: "activity.commented", + icon: MessageSquareIcon, + labelKey: "types.forum_comment", + }, + "forum.topic": { + activityKey: "activity.posted_in", + icon: MessagesSquareIcon, + labelKey: "types.forum_topic", + }, }; const fallback: SearchTypeRenderer = { diff --git a/packages/vitnode/src/views/search/search-controls-content.tsx b/packages/vitnode/src/views/search/search-controls-content.tsx index a016cf61de..3269d8d6e1 100644 --- a/packages/vitnode/src/views/search/search-controls-content.tsx +++ b/packages/vitnode/src/views/search/search-controls-content.tsx @@ -1,7 +1,8 @@ -import { SearchIcon } from "lucide-react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { SearchIcon, SlidersHorizontalIcon, XIcon } from "lucide-react"; import React from "react"; import { useDebouncedCallback } from "use-debounce"; -import { useTranslations } from "use-intl"; +import { useLocale, useTranslations } from "use-intl"; import { Button } from "@/components/ui/button"; import { @@ -9,26 +10,42 @@ import { InputGroupAddon, InputGroupInput, } from "@/components/ui/input-group"; +import { Kbd } from "@/components/ui/kbd"; import { NativeSelect, NativeSelectOption, } from "@/components/ui/native-select"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; import type { SearchFeedVariant } from "./search-feed-content"; import type { SearchFeedParams, SearchFeedQueryOptions, } from "./search-feed-query"; +import type { SearchFilterValue } from "./search-filters"; import type { SearchSort } from "./search-params"; -import { getSearchTypeRenderer, searchTypeKeys } from "./registry"; -import { SearchFeedContent } from "./search-feed-content"; +import { SearchFeedList } from "./search-feed-content"; +import { + activeSearchFilterCount, + EMPTY_SEARCH_FILTERS, + SearchFilters, +} from "./search-filters"; +import { searchFacetsQueryOptions } from "./search-filters-query"; import { appliedSearchTerm, defaultSearchSort, parseSearchTypes, SEARCH_SORT_VALUES, SEARCH_TERM_DEBOUNCE_MS, + searchDatePresetStart, + searchDayRangeBounds, searchFeedParamsFor, sortForAppliedTerm, } from "./search-params"; @@ -37,6 +54,82 @@ export type SearchFeedQueryFactory = ( params: SearchFeedParams, ) => SearchFeedQueryOptions; +const isTyping = (target: EventTarget | null) => + target instanceof HTMLElement && + (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || + target.isContentEditable); + +const dateBoundsOf = ( + filters: SearchFilterValue, + anchor: Date, +): { from?: string; to?: string } => { + if (filters.range === "all") return {}; + if (filters.range === "custom") { + return filters.customRange ? searchDayRangeBounds(filters.customRange) : {}; + } + + return { from: searchDatePresetStart(filters.range, anchor).toISOString() }; +}; + +const SearchField = ({ + onChange, + value, +}: { + onChange: (value: string) => void; + value: string; +}) => { + const t = useTranslations("core.search"); + const inputRef = React.useRef(null); + + React.useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) + return; + if (isTyping(event.target)) return; + event.preventDefault(); + inputRef.current?.focus(); + }; + document.addEventListener("keydown", onKey); + + return () => document.removeEventListener("keydown", onKey); + }, []); + + return ( + + + + + onChange(event.target.value)} + placeholder={t("searchField")} + ref={inputRef} + type="search" + value={value} + /> + + {value ? ( + + ) : ( + / + )} + + + ); +}; + export const SearchControlsContent = ({ defaultParams, feedQuery, @@ -47,13 +140,16 @@ export const SearchControlsContent = ({ variant?: SearchFeedVariant; }) => { const t = useTranslations("core.search"); + const locale = useLocale(); const [term, setTerm] = React.useState(defaultParams.search ?? ""); const [appliedTerm, setAppliedTerm] = React.useState( defaultParams.search ?? "", ); - const [types, setTypes] = React.useState(() => - parseSearchTypes(defaultParams.types), - ); + const [filters, setFilters] = React.useState(() => ({ + ...EMPTY_SEARCH_FILTERS, + types: parseSearchTypes(defaultParams.types), + })); + const [rangeAnchor, setRangeAnchor] = React.useState(() => new Date()); const [sort, setSort] = React.useState( () => defaultParams.sort ?? defaultSearchSort(defaultParams.search), ); @@ -67,61 +163,99 @@ export const SearchControlsContent = ({ if (next.length > 0) setSort(sortForAppliedTerm); }, SEARCH_TERM_DEBOUNCE_MS); - const toggleType = (key: string) => { - setTypes(prev => - prev.includes(key) ? prev.filter(item => item !== key) : [...prev, key], - ); + const changeFilters = (next: SearchFilterValue) => { + if (next.range !== filters.range) setRangeAnchor(new Date()); + setFilters(next); }; - const params = searchFeedParamsFor({ search: appliedTerm, sort, types }); + const params = searchFeedParamsFor({ + authorIds: filters.authors.map(author => author.id), + search: appliedTerm, + sort, + types: filters.types, + ...dateBoundsOf(filters, rangeAnchor), + }); + const query = useInfiniteQuery(feedQuery(params)); + const facets = useQuery(searchFacetsQueryOptions({ locale, params })); + const total = query.data?.pages[0]?.pageInfo.totalCount; + const active = activeSearchFilterCount(filters); + + const sidebar = ( + + ); return ( -
- - { - setTerm(e.target.value); - applyTerm(e.target.value); - }} - placeholder={t("placeholder")} - type="search" - value={term} - /> - - - - - -
- {searchTypeKeys.map(key => ( - - ))} - - setSort(e.target.value as SearchSort)} - size="sm" - value={sort} +
+ { + setTerm(value); + applyTerm(value); + }} + value={term} + /> + +
+
+ {sidebar} + - +
+
+ + }> + + {t("filters.title")} + {active > 0 && ( + + {active} + + )} + + + + {t("filters.title")} + +
+ {sidebar} +
+
+
+
+ +
+

+ {total === undefined + ? t("loading") + : t("results", { count: total })} +

+ setSort(event.target.value as SearchSort)} + size="sm" + value={sort} + > + {SEARCH_SORT_VALUES.map(value => ( + + {t(`sort.${value}`)} + + ))} + +
+ + +
+
); }; diff --git a/packages/vitnode/src/views/search/search-feed-content.tsx b/packages/vitnode/src/views/search/search-feed-content.tsx index a366cb4db7..561ec47c35 100644 --- a/packages/vitnode/src/views/search/search-feed-content.tsx +++ b/packages/vitnode/src/views/search/search-feed-content.tsx @@ -2,7 +2,14 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { cn } from "cn"; import React from "react"; -import { useFormatter, useTranslations } from "use-intl"; +import { + useFormatter, + useLocale, + useNow, + useTimeZone, + useTranslations, +} from "use-intl"; +import { z } from "zod"; import { Avatar } from "@/components/avatar"; import { DateFormat } from "@/components/date-format"; @@ -10,9 +17,12 @@ import { HiddenIcon } from "@/components/hidden-icon"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { FEEDBACK_TONE_TEXT } from "@/components/ui/feedback-tone"; import { Skeleton } from "@/components/ui/skeleton"; import { TooltipWithContent } from "@/components/ui/tooltip"; +import { userProfileHref } from "@/components/user-format"; +import type { SearchActivityKey } from "./registry"; import type { SearchFeedQueryOptions } from "./search-feed-query"; import type { SearchFeedPage, @@ -206,83 +216,568 @@ const ResultImage = ({ image }: { image: SearchResultItem["image"] }) => /> ) : null; +const zodActivityContext = z.object({ + context: z.object({ title: z.string().min(1), url: z.string().nullable() }), +}); + +interface SearchActivity { + context: { title: string; url: null | string }; + key: SearchActivityKey; +} + +const activityOf = (item: SearchResultItem): null | SearchActivity => { + const { activityKey } = getSearchTypeRenderer(item.itemType); + if (!activityKey) return null; + + const parsed = zodActivityContext.safeParse(item.metadata); + + return parsed.success + ? { context: parsed.data.context, key: activityKey } + : null; +}; + +const ActivityLink = ({ + children, + href, +}: { + children: React.ReactNode; + href: null | string; +}) => + href ? ( + + {children} + + ) : ( + {children} + ); + +const CARD_OVERLAY_LINK = + "after:absolute after:inset-0 after:rounded-lg focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring"; + +const ActivitySentence = ({ + activity, + authors, + item, +}: { + activity: SearchActivity; + authors: SearchResultAuthor[]; + item: SearchResultItem; +}) => { + const t = useTranslations("core.search"); + const format = useFormatter(); + const [onlyAuthor] = authors.length === 1 ? authors : []; + + return ( + + {t.rich(activity.key, { + author: format.list(authors.map(author => author.name)), + context: activity.context.title, + item: item.title, + link: chunks => {chunks}, + profile: chunks => ( + + {chunks} + + ), + target: chunks => ( + {chunks} + ), + })} + + ); +}; + +const ActivityBody = ({ + className, + item, + overlayClassName = CARD_OVERLAY_LINK, + snippet, +}: { + className?: string; + item: SearchResultItem; + overlayClassName?: string; + snippet: string; +}) => { + if (item.url && !snippet) { + return ( + + {item.title} + + ); + } + if (!snippet) return null; + + return item.url ? ( + + {snippet} + + ) : ( +

+ {snippet} +

+ ); +}; + const DraftBadge = () => { const t = useTranslations("core.search"); return {t("draft")}; }; -const TimelineItem = ({ +const DAY_MS = 86_400_000; + +const SURFACE = "bg-card rounded-xl shadow-xs ring-1 ring-foreground/8"; + +const CARD_OVERLAY_LINK_XL = + "after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring"; + +const dateOf = (item: SearchResultItem): Date => + typeof item.createdAt === "string" + ? new Date(item.createdAt) + : item.createdAt; + +const useDayKey = () => { + const timeZone = useTimeZone(); + const formatter = React.useMemo( + () => + new Intl.DateTimeFormat("en-CA", { + day: "2-digit", + month: "2-digit", + timeZone, + year: "numeric", + }), + [timeZone], + ); + + return (date: Date) => formatter.format(date); +}; + +const dayKeyDate = (key: string) => + new Date(`${key.length === 7 ? `${key}-15` : key}T12:00:00Z`); + +const groupConsecutive = ( + items: readonly T[], + keyOf: (item: T) => string, +) => { + const groups: { items: T[]; key: string }[] = []; + + for (const item of items) { + const key = keyOf(item); + const last = groups.at(-1); + if (last?.key === key) last.items.push(item); + else groups.push({ items: [item], key }); + } + + return groups; +}; + +const useDayHeading = (todayKey: string) => { + const locale = useLocale(); + const format = useFormatter(); + + return (key: string) => { + const diff = Math.round( + (dayKeyDate(todayKey).getTime() - dayKeyDate(key).getTime()) / DAY_MS, + ); + + if (diff === 0 || diff === 1) { + const word = new Intl.RelativeTimeFormat(locale, { + numeric: "auto", + }).format(-diff, "day"); + + return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1); + } + + return format.dateTime(dayKeyDate(key), { + day: "numeric", + month: "long", + weekday: "long", + }); + }; +}; + +const DayTile = ({ date, isToday }: { date: Date; isToday: boolean }) => { + const format = useFormatter(); + + return ( + + ); +}; + +const RailNode = ({ itemType }: { itemType: string }) => { + const t = useTranslations("core.search"); + const renderer = getSearchTypeRenderer(itemType); + const Icon = renderer.icon; + + return ( + + + + + + ); +}; + +const CardMeta = ({ + children, item, - isLast, }: { - isLast: boolean; + children?: React.ReactNode; item: SearchResultItem; }) => { const t = useTranslations("core.search"); - const renderer = getSearchTypeRenderer(item.itemType); - const Icon = renderer.icon; - const snippet = getSnippet(item.content); + + return ( +
+ + {t(getSearchTypeRenderer(item.itemType).labelKey)} + + {children} + + + {!item.isPublic && !item.isHidden && } +
+ ); +}; + +const CardTitle = ({ + className, + item, +}: { + className: string; + item: SearchResultItem; +}) => ( +

+ {item.url ? ( + + {item.title} + + ) : ( + item.title + )} +

+); + +const visibleAuthorsOf = ( + item: SearchResultItem, + ownerId?: number, +): SearchResultAuthor[] => { const authors = authorsOf(item); + const [only] = authors; - const content = ( - <> -
-
- {authors.length > 0 && ( - <> - - - - - - · - - - )} - - {!item.isPublic && !item.isHidden && } -
-

- - {item.title} -

- {snippet &&

{snippet}

} + return ownerId !== undefined && authors.length === 1 && only?.id === ownerId + ? [] + : authors; +}; + +const CardAuthors = ({ authors }: { authors: SearchResultAuthor[] }) => + authors.length > 0 ? ( +
+ + + + +
+ ) : null; + +const cardSurface = (item: SearchResultItem, className: string) => + cn( + SURFACE, + "relative min-w-0 flex-1", + item.url && "hover:ring-foreground/15 transition-shadow", + item.isHidden && "opacity-70", + className, + ); + +const ArticleCard = ({ + item, + ownerId, +}: { + item: SearchResultItem; + ownerId?: number; +}) => { + const snippet = getSnippet(item.content); + + return ( +
+ {item.image && ( + + )} +
+ + + {snippet && ( +

+ {snippet} +

+ )} +
- - +
); +}; + +const TopicCard = ({ + activity, + item, + ownerId, +}: { + activity: SearchActivity; + item: SearchResultItem; + ownerId?: number; +}) => { + const t = useTranslations("core.search"); + const snippet = getSnippet(item.content); return ( -
  • -
    - - - +
    +
    + + + {t.rich("timeline.in_context", { + context: activity.context.title, + target: chunks => ( + + {chunks} + + ), + })} - - {!isLast && } + + + {snippet && ( +

    + {snippet} +

    + )} +
    + {item.image && ( + + )} +
    + ); +}; -
    - {item.url ? ( - - {content} - - ) : ( -
    - {content} -
    - )} +const CommentCard = ({ + activity, + item, + ownerId, +}: { + activity: SearchActivity; + item: SearchResultItem; + ownerId?: number; +}) => { + const t = useTranslations("core.search"); + const snippet = getSnippet(item.content); + const authors = visibleAuthorsOf(item, ownerId); + + return ( +
    + +
    + {authors.length > 0 && } +

    + {authors.length > 0 ? ( + + ) : ( + t.rich("activity.commented_self", { + context: activity.context.title, + target: chunks => ( + + {chunks} + + ), + }) + )} +

    -
  • + {snippet ? ( +
    + +
    + ) : ( + + )} + + ); +}; + +const TimelineCard = ({ + item, + ownerId, +}: { + item: SearchResultItem; + ownerId?: number; +}) => { + const activity = activityOf(item); + + if (activity?.key === "activity.commented") { + return ; + } + if (activity) { + return ; + } + + return ; +}; + +const SearchTimeline = ({ + items, + ownerId, +}: { + items: SearchResultItem[]; + ownerId?: number; +}) => { + const t = useTranslations("core.search"); + const format = useFormatter(); + const dayKey = useDayKey(); + const todayKey = dayKey(useNow({ updateInterval: 60_000 })); + const dayHeading = useDayHeading(todayKey); + const months = groupConsecutive(items, item => + dayKey(dateOf(item)).slice(0, 7), + ); + + return ( +
    + {months.map((month, monthIndex) => { + const days = groupConsecutive(month.items, item => + dayKey(dateOf(item)), + ); + const headingId = `search-month-${month.key}`; + + return ( +
    +

    + {format.dateTime(dayKeyDate(month.key), { + month: "long", + year: "numeric", + })} +

    +
      + {days.map((day, dayIndex) => ( +
    1. +
      +
      + {dayIndex > 0 && ( + + )} + +
      +

      0 && "pt-4", + )} + > + + {dayHeading(day.key)} + + + · {t("timeline.items", { count: day.items.length })} + +

      +
      +
        + {day.items.map((item, itemIndex) => { + const isLast = + monthIndex === months.length - 1 && + dayIndex === days.length - 1 && + itemIndex === day.items.length - 1; + + return ( +
      1. +
        + + + {!isLast && ( + + )} +
        +
        + +
        +
      2. + ); + })} +
      +
    2. + ))} +
    +
    + ); + })} +
    ); }; @@ -292,9 +787,14 @@ const SearchResultCard = ({ item }: { item: SearchResultItem }) => { const Icon = renderer.icon; const snippet = getSnippet(item.content); const authors = authorsOf(item); + const activity = activityOf(item); return ( - +
    {item.author ? ( @@ -311,22 +811,40 @@ const SearchResultCard = ({ item }: { item: SearchResultItem }) => { {t(renderer.labelKey)} - {authors.length > 0 && } + {activity ? ( + + ) : ( + authors.length > 0 && + )} {!item.isPublic && !item.isHidden && }
    -

    - - - - -

    - - {snippet && ( -

    - {snippet} -

    + {activity ? ( + + ) : ( + <> +

    + + + + +

    + + {snippet && ( +

    + {snippet} +

    + )} + )}
    @@ -345,9 +863,11 @@ export interface SearchFeedListQuery { } export const SearchFeedList = ({ + ownerId, query: { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading }, variant = "list", }: { + ownerId?: number; query: SearchFeedListQuery; variant?: SearchFeedVariant; }) => { @@ -405,17 +925,8 @@ export const SearchFeedList = ({ if (variant === "timeline") { return ( -
    -
      - {items.map((item, index) => ( - - ))} -
    - +
    + {loadMore}
    ); diff --git a/packages/vitnode/src/views/search/search-feed-query.ts b/packages/vitnode/src/views/search/search-feed-query.ts index 74f51e5144..66f64d9616 100644 --- a/packages/vitnode/src/views/search/search-feed-query.ts +++ b/packages/vitnode/src/views/search/search-feed-query.ts @@ -16,6 +16,7 @@ export const SEARCH_FEED_FIRST_PAGE: SearchFeedCursor = null; export interface SearchFeedParams { authorId?: string; + authorIds?: string; from?: string; search?: string; sort?: "newest" | "oldest" | "relevance"; @@ -57,6 +58,7 @@ export const searchFeedQuery = ({ if (params.search) query.search = params.search; if (params.types) query.types = params.types; if (params.authorId) query.authorId = params.authorId; + if (params.authorIds) query.authorIds = params.authorIds; if (params.sort) query.sort = params.sort; if (params.from) query.from = params.from; if (params.to) query.to = params.to; diff --git a/packages/vitnode/src/views/search/search-filters-query.ts b/packages/vitnode/src/views/search/search-filters-query.ts new file mode 100644 index 0000000000..d1acbb8cb1 --- /dev/null +++ b/packages/vitnode/src/views/search/search-filters-query.ts @@ -0,0 +1,85 @@ +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; + +import { CONFIG_PLUGIN } from "@/config"; +import { RECORD_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; + +import type { SearchFeedParams } from "./search-feed-query"; + +import { SEARCH_QUERY_ROOT } from "./search-feed-query"; + +const facetsQuery = ({ + locale, + params, +}: { + locale: string; + params: SearchFeedParams; +}): Record => { + const query: Record = { lang: locale }; + + if (params.search) query.search = params.search; + if (params.authorIds) query.authorIds = params.authorIds; + if (params.from) query.from = params.from; + if (params.to) query.to = params.to; + + return query; +}; + +export const searchFacetsQueryOptions = ({ + locale, + params, +}: { + locale: string; + params: SearchFeedParams; +}) => { + const query = facetsQuery({ locale, params }); + + return queryOptions({ + placeholderData: keepPreviousData, + queryFn: async ({ signal }) => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query }, + method: "get", + module: "search", + options: { signal }, + path: "/facets", + }); + + if (!response.ok) { + throw new Error( + `The search API answered ${response.status} for the type counts.`, + ); + } + + return await response.json(); + }, + queryKey: [...SEARCH_QUERY_ROOT, "facets", query] as const, + staleTime: RECORD_STALE_TIME, + }); +}; + +export const searchAuthorsQueryOptions = ({ term }: { term: string }) => + queryOptions({ + placeholderData: keepPreviousData, + queryFn: async ({ signal }) => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: term ? { search: term } : {} }, + method: "get", + module: "search", + options: { signal }, + path: "/authors", + }); + + if (!response.ok) { + throw new Error( + `The search API answered ${response.status} for the author lookup.`, + ); + } + + return await response.json(); + }, + queryKey: [...SEARCH_QUERY_ROOT, "authors", term] as const, + staleTime: RECORD_STALE_TIME, + }); diff --git a/packages/vitnode/src/views/search/search-filters.tsx b/packages/vitnode/src/views/search/search-filters.tsx new file mode 100644 index 0000000000..b99755ed92 --- /dev/null +++ b/packages/vitnode/src/views/search/search-filters.tsx @@ -0,0 +1,510 @@ +import { useQuery } from "@tanstack/react-query"; +import { cn } from "cn"; +import { + CalendarIcon, + CheckIcon, + ChevronDownIcon, + PlusIcon, + SearchIcon, + XIcon, +} from "lucide-react"; +import React from "react"; +import { useFormatter, useNow, useTranslations } from "use-intl"; + +import { Avatar } from "@/components/avatar"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; + +import type { SearchDateRange, SearchDayRange } from "./search-params"; +import type { SearchResultAuthor } from "./types"; + +import { getSearchTypeRenderer, searchTypeKeys } from "./registry"; +import { searchAuthorsQueryOptions } from "./search-filters-query"; +import { SEARCH_DATE_PRESETS, SEARCH_TERM_DEBOUNCE_MS } from "./search-params"; + +export interface SearchFilterValue { + authors: SearchResultAuthor[]; + customRange: null | SearchDayRange; + range: SearchDateRange; + types: string[]; +} + +export const EMPTY_SEARCH_FILTERS: SearchFilterValue = { + authors: [], + customRange: null, + range: "all", + types: [], +}; + +export const activeSearchFilterCount = ({ + authors, + range, + types, +}: SearchFilterValue): number => + types.length + authors.length + (range === "all" ? 0 : 1); + +const ROW = + "hover:bg-muted flex min-h-9 cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition-colors"; + +const middayOf = (date: Date) => + new Date(date.getFullYear(), date.getMonth(), date.getDate(), 12); + +const isTouchDevice = () => + typeof window !== "undefined" && "ontouchstart" in window; + +const LinkButton = ({ + children, + onClick, +}: { + children: React.ReactNode; + onClick: () => void; +}) => ( + +); + +const Group = ({ + children, + label, + onReset, +}: { + children: React.ReactNode; + label: string; + onReset?: () => void; +}) => { + const t = useTranslations("core.search.filters"); + + return ( +
    +
    + + {label} + + {onReset && {t("reset")}} +
    + {children} +
    + ); +}; + +const TypeGroup = ({ + counts, + onChange, + types, +}: { + counts?: null | Record; + onChange: (types: string[]) => void; + types: string[]; +}) => { + const t = useTranslations("core.search"); + + return ( + 0 ? () => onChange([]) : undefined} + > + {searchTypeKeys.map(key => { + const renderer = getSearchTypeRenderer(key); + const Icon = renderer.icon; + const checked = types.includes(key); + + return ( + + ); + })} + + ); +}; + +const useDayRangeLabel = (range: null | SearchDayRange) => { + const format = useFormatter(); + + return range + ? format.dateTimeRange(range.from, range.to, { + day: "numeric", + month: "short", + year: "numeric", + }) + : ""; +}; + +const CustomRangeField = ({ + autoOpen, + onChange, + range, +}: { + autoOpen: boolean; + onChange: (range: SearchDayRange) => void; + range: SearchDayRange; +}) => { + const t = useTranslations("core.search.filters"); + const [open, setOpen] = React.useState(autoOpen); + const label = useDayRangeLabel(range); + const today = useNow(); + + return ( + + + } + > + + {label} + + + + { + if (!selected?.from) return; + onChange({ + from: middayOf(selected.from), + to: middayOf(selected.to ?? selected.from), + }); + }} + selected={range} + /> +
    + + {t("pickDates")} + + +
    +
    +
    + ); +}; + +const defaultCustomRange = (): SearchDayRange => { + const today = new Date(); + + return { + from: new Date(today.getFullYear(), today.getMonth(), 1, 12), + to: middayOf(today), + }; +}; + +const DateGroup = ({ + customRange, + onChange, + range, +}: { + customRange: null | SearchDayRange; + onChange: (next: Pick) => void; + range: SearchDateRange; +}) => { + const t = useTranslations("core.search"); + const [openCalendar, setOpenCalendar] = React.useState(false); + const options: SearchDateRange[] = ["all", ...SEARCH_DATE_PRESETS, "custom"]; + + return ( + onChange({ customRange, range: "all" }) + } + > + { + const next = value as SearchDateRange; + setOpenCalendar(next === "custom"); + onChange({ + customRange: + next === "custom" + ? (customRange ?? defaultCustomRange()) + : customRange, + range: next, + }); + }} + value={range} + > + {options.map(option => ( + + ))} + + {range === "custom" && customRange && ( +
    + onChange({ customRange: next, range: "custom" })} + range={customRange} + /> +
    + )} +
    + ); +}; + +const useDebouncedValue = (value: string, delay: number) => { + const [debounced, setDebounced] = React.useState(value); + + React.useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +}; + +const AuthorOptions = ({ + onToggle, + selected, + term, +}: { + onToggle: (author: SearchResultAuthor) => void; + selected: SearchResultAuthor[]; + term: string; +}) => { + const t = useTranslations("core.search.filters"); + const debounced = useDebouncedValue(term.trim(), SEARCH_TERM_DEBOUNCE_MS / 2); + const { data, isLoading } = useQuery( + searchAuthorsQueryOptions({ term: debounced }), + ); + const authors = data?.authors ?? []; + + if (isLoading) { + return ( +

    + {t("loadingAuthors")} +

    + ); + } + + if (authors.length === 0) { + return ( +

    + {t("noAuthors", { term })} +

    + ); + } + + return ( + <> + {authors.map(author => { + const pressed = selected.some(entry => entry.id === author.id); + + return ( + + ); + })} + + ); +}; + +const AuthorGroup = ({ + authors, + onChange, +}: { + authors: SearchResultAuthor[]; + onChange: (authors: SearchResultAuthor[]) => void; +}) => { + const t = useTranslations("core.search.filters"); + const [open, setOpen] = React.useState(false); + const [term, setTerm] = React.useState(""); + + const toggle = (author: SearchResultAuthor) => + onChange( + authors.some(entry => entry.id === author.id) + ? authors.filter(entry => entry.id !== author.id) + : [...authors, author], + ); + + return ( + 0 ? () => onChange([]) : undefined} + > +
    +
    + {authors.map(author => ( + + + {author.name} + + + ))} + { + setOpen(next); + if (!next) setTerm(""); + }} + open={open} + > + + } + > + + {authors.length > 0 ? t("addAuthor") : t("anyAuthor")} + + + +
    + {open && ( + + )} +
    +
    +
    +
    +
    +
    + ); +}; + +export const SearchFilters = ({ + onChange, + typeCounts, + value, +}: { + onChange: (value: SearchFilterValue) => void; + typeCounts?: null | Record; + value: SearchFilterValue; +}) => { + const t = useTranslations("core.search.filters"); + const active = activeSearchFilterCount(value); + + return ( +
    +
    + + {t("active", { count: active })} + + onChange(EMPTY_SEARCH_FILTERS)}> + {t("clearAll")} + +
    + onChange({ ...value, types })} + types={value.types} + /> + onChange({ ...value, ...next })} + range={value.range} + /> + onChange({ ...value, authors })} + /> +
    + ); +}; diff --git a/packages/vitnode/src/views/search/search-params.ts b/packages/vitnode/src/views/search/search-params.ts index cd1db4f7f4..c38154e237 100644 --- a/packages/vitnode/src/views/search/search-params.ts +++ b/packages/vitnode/src/views/search/search-params.ts @@ -68,13 +68,66 @@ export const serializeSearchTypes = (value: unknown): string | undefined => { return types.length > 0 ? types.join(",") : undefined; }; +export const SEARCH_DATE_PRESETS = ["day", "week", "month", "year"] as const; + +export type SearchDatePreset = (typeof SEARCH_DATE_PRESETS)[number]; + +export type SearchDateRange = "all" | "custom" | SearchDatePreset; + +export interface SearchDayRange { + from: Date; + to: Date; +} + +export const searchDatePresetStart = ( + preset: SearchDatePreset, + now: Date, +): Date => { + const start = new Date(now); + + if (preset === "day") start.setHours(start.getHours() - 24); + if (preset === "week") start.setDate(start.getDate() - 7); + if (preset === "month") start.setMonth(start.getMonth() - 1); + if (preset === "year") start.setFullYear(start.getFullYear() - 1); + + return start; +}; + +export const searchDayRangeBounds = ({ + from, + to, +}: SearchDayRange): { from: string; to: string } => { + const start = new Date(from); + start.setHours(0, 0, 0, 0); + const end = new Date(to); + end.setHours(23, 59, 59, 999); + + return { from: start.toISOString(), to: end.toISOString() }; +}; + +export const serializeSearchAuthorIds = ( + ids: readonly number[], +): string | undefined => { + const unique = [...new Set(ids)] + .filter(id => Number.isSafeInteger(id) && id > 0) + .sort((a, b) => a - b); + + return unique.length > 0 ? unique.join(",") : undefined; +}; + export const searchFeedParamsFor = ({ + authorIds, + from, search, sort, + to, types, }: { + authorIds?: readonly number[]; + from?: string; search?: unknown; sort?: unknown; + to?: string; types?: unknown; } = {}): SearchFeedParams => { const term = normalizeSearchTerm(search); @@ -87,6 +140,11 @@ export const searchFeedParamsFor = ({ const filter = serializeSearchTypes(types); if (filter !== undefined) params.types = filter; + const authors = authorIds ? serializeSearchAuthorIds(authorIds) : undefined; + if (authors !== undefined) params.authorIds = authors; + if (from) params.from = from; + if (to) params.to = to; + return params; }; diff --git a/plans/community-forum.md b/plans/community-forum.md new file mode 100644 index 0000000000..1b38d190e5 --- /dev/null +++ b/plans/community-forum.md @@ -0,0 +1,224 @@ +# Content Engine: container policies, approvals, threads and the Forum plugin + +Status: in progress on canary. Owner: Content Engine + `@vitnode/forum`. + +Stages covered: 3 (container policies and scoped permissions), 4 (approval +workflow and ModCP queue), 5 (threaded content), 8 (Forum plugin MVP), 10 +(performance, SEO, DX). Deferred: reactions (6), subscriptions (7), reports and +ModCP integration of reports for forum content (9). + +## Audit (canary @ 95428da8) + +- Stage 3 is **not** implemented. `resolveContentPublicationPolicy()` returns + `publicWrite.create.status`; containers have no policy storage. +- Scoped moderator permissions exist (moderation MVP): a permission entry may + declare `scope: ""`, grants carry `{ family, mode, ids }`, and + `hasStaffPermission(set, { resource: { family, id } })` decides. Root roles + pass every check. +- A `draft` row never reaches public list, detail, slug, delivery, sitemap, + public search or report capture. Search indexes drafts as private documents. +- Editorial `update` writes the live row; there is no shadow copy. Revisions + are unique per `(type, item, version)` and pruned by retention, so a pending + proposal cannot live in `core_content_revisions`. +- To-one `field.relation({ self: true })` throws when the table is built. +- `publicApi` requires exactly one slug field and forbids user fields. +- Moderator permission modules are written by several registrars that skip a + module already present, so `publicWrite` `can_edit` and the report + permissions cannot coexist on one content type. + +## Stage 3 - container policies + +### Storage + +`core_content_containers.policies jsonb not null default '{}'`: + +```ts +type ContentContainerPolicyOverrides = { + [contentTypeId: string]: Partial; +}; +interface ContentContainerPolicy { + create: "members" | "moderators"; // who may create + publish: "immediate" | "approve"; // new records + edits: "immediate" | "approve"; // author edits of published records + authorEdit: "always" | "never" | "window"; + authorEditMinutes: number; // used by "window" +} +``` + +A missing key means "inherit". An explicit value - including `"never"` - is an +override. The engine never stores `undefined`. + +### Definition + +```ts +defineContentContainer({ + id: "forum.forums", + policies: { + defaults: { + publish: "immediate", + edits: "immediate", + authorEdit: "always", + }, + contentTypes: { + "forum.reply": { authorEdit: "window", authorEditMinutes: 60 }, + }, + }, +}); +``` + +`policies` absent = the container does not participate, and content types in it +keep their pre-Stage-3 behaviour (`publicWrite.create.status`, owners edit +forever, unscoped `can_edit`). + +### Effective policy (pure, `resolveContentContainerPolicy`) + +For every key independently: + +1. the current container's override for the content type; +2. the nearest ancestor's override (walking `parentId`); +3. `definition.policies.contentTypes[contentTypeId]`; +4. `definition.policies.defaults`; +5. the engine fallback: `create: "members"`, `publish` from + `publicWrite.create.status` (`published` -> `immediate`, `draft` -> + `approve`), `edits: "immediate"`, `authorEdit: "always"`, + `authorEditMinutes: 60`. + +### Container of a record + +A content type's container comes from either its own `field.container()` or a +to-one relation marked `field.relation({ target, container: true })`, which +inherits the related record's container (a reply lives in its topic's forum). +The inherited value is never client-writable and is always resolved on the +server from the related row. + +### Scoped permissions + +- Every container definition with `policies` registers a moderation scope + family with the container definition id (`forum.forums`). Resources are + container ids (`"12"`) and explicit subtrees (`"12:tree"`, "this forum and + every forum below it"). A plain id never covers descendants. +- Content types in such a container get moderator permissions under their + `permissionModule`, scoped by that family: `can_edit` (editAny), `can_approve` + (approve and see the queue). Entries merge with the report permissions + instead of replacing them. +- A record in container `C` with ancestors `A1..An` is covered by a grant whose + ids contain `C`, `C:tree` or any `Ai:tree`, or by an unscoped / `mode: "all"` + grant, or by a root role. Being staff alone grants nothing. +- `create: "moderators"` lets only `can_edit` holders in that container create. +- Holders of `can_approve` in the container skip approval for their own posts. + +### Author editing + +`editOwn` is allowed while the record is not hidden and the effective +`authorEdit` allows it: `always`, `never`, or `window` measured from the +record's server `createdAt`. Moderator editing (`can_edit` in scope) is +independent of the window. + +### AdminCP + +`GET/PUT /admin/.../content/{containerModule}/{id}/policies` returns the +overrides, the effective policy per content type and where each value came from; +`PUT` replaces the overrides after strict validation. The container sheet gets a +"Publishing" section per content type with an "Inherit" choice for every key. + +## Stage 4 - approvals + +### Model + +`core_content_submissions` (one row per proposal, never deleted with content): + +| column | notes | +| -------------------------------------- | ------------------------------------------------------------ | +| `id` | serial | +| `pluginId`, `contentTypeId`, `itemId` | target; `itemId` is `contentIdKey` | +| `containerId`, `containerDefinitionId` | scope at submission time, refreshed at decision | +| `kind` | `create` or `edit` | +| `status` | `pending`, `approved`, `rejected`, `superseded`, `cancelled` | +| `values` | proposed values (writable fields only) | +| `baseValues` | the live values the edit was made against | +| `baseVersion` | editorial version the edit was made against | +| `authorId`, `reviewerId` | users, `set null` on delete | +| `reason` | rejection reason | +| `revisionId` | revision written by the approval | +| `createdAt`, `updatedAt`, `decidedAt` | server timestamps | + +Partial unique index: one `pending` row per `(contentTypeId, itemId)`. + +Publication stays `draft | published`. A pending **create** is a `draft` row +plus a `create` submission - nothing public can reach a draft. A pending +**edit** never touches the row; the proposal lives in `values`. + +### Rules + +- The server decides the outcome from the effective policy; the client never + sends a status. +- Author creates in an `approve` container: row stored as draft, submission + `pending`, response carries `approval`. +- Author edits a published record with `edits: "approve"`: validated like a + normal edit, stored as a proposal. A second edit **replaces** the pending + proposal (old row `superseded`). The live row and its version do not change. +- Author edits a record whose create is pending: the draft row is updated in + place, the pending submission's `values` follow it. +- Author edits a record whose create was rejected: the draft row is updated and + a new `create` submission is opened (resubmission). +- Moderator edits always apply immediately. If a proposal is pending its base + version is now stale; approving it answers `409 stale` and the moderator + rejects it (the author may resubmit). +- Approve/reject lock the submission row (`FOR UPDATE`) and require + `status = 'pending'`; a second decision answers `409 decided`. +- Decisions re-resolve the record's **current** container and authorize + `can_approve` against it. A record that is gone cancels the submission + (`409 gone`). +- Approving a create publishes the draft (one `published` event). Approving an + edit runs the editorial update with `expectedVersion = baseVersion` (one + `updated` event, one revision). Rejecting changes nothing on the record. +- Hidden records keep their `hiddenAt`; approving a hidden create publishes it + but it stays hidden. +- Policy changes do not touch pending submissions. + +### Events + +`content.approval.submitted`, `content.approval.approved`, +`content.approval.rejected` with +`{ submissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId, reviewerId?, reason? }`. + +### API (core `moderation` module, `approvals` sub-paths) + +- `GET /moderation/approvals` - scoped queue (page, pageSize, status, contentTypeId, containerId). +- `GET /moderation/approvals/filters` - content types and containers the moderator may review. +- `GET /moderation/approvals/{id}` - proposal, current live values, history. +- `POST /moderation/approvals/{id}/approve`, `POST /moderation/approvals/{id}/reject`. +- `GET /moderation/approvals/mine` - the signed-in author's submissions. + +### UI + +ModCP `/modcp/approvals` (list + detail with compare, approve, reject). + +## Stage 5 - threads + +- To-one self relations are supported. +- `thread: { parent: "", root: "", maxDepth }` + on a content type: the parent must be the same type, in the same root, public, + and above `maxDepth`. The parent is set on create only, so cycles cannot form. +- `GET /{path}/thread?root=&first&after` pages top-level replies (oldest + first, `createdAt, id`) and returns their public descendants. A non-public + ancestor of a public reply is returned as a tombstone (`removed: true`, no + values) so the tree stays intact. +- `field.slug({ generated: true })` gives records without a natural title (a + reply) a server-generated permalink slug. +- `publicApi.people: ["author"]` publishes a user field as a public profile + summary (name, nameCode, avatar, role color/prefix) - an explicit opt-in next + to the strict `fields` allowlist. + +## Stage 8 - Forum plugin (`@vitnode/forum`) + +- `forum.forums` container (policies on), `forum.topic`, `forum.reply`. +- Pages: `/forum`, `/forum/:slug`, `/forum/topics/new`, `/forum/topics/:slug`, + `/forum/topics/:id/edit`. +- Topic stats (public reply count, last activity) from a plugin route. + +## Stage 10 + +Indexes for thread and listing queries, page cache on public routes, canonical +URLs, JSON-LD `DiscussionForumPosting`, sitemap via delivery, docs for every +feature. diff --git a/plugins/blog/package.json b/plugins/blog/package.json index 16cc7083f8..ae7d2af219 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -38,7 +38,7 @@ "dependencies": { "@hono/zod-openapi": "^1.6.3", "@tanstack/react-form": "^1.33.5", - "@tanstack/react-router": "^1.170.41", + "@tanstack/react-router": "^1.170.44", "@vitnode/core": "workspace:*", "ai": "^7.0.134", "drizzle-kit": "1.0.0-rc.4", diff --git a/plugins/forum/.npmignore b/plugins/forum/.npmignore new file mode 100644 index 0000000000..6eced5b18e --- /dev/null +++ b/plugins/forum/.npmignore @@ -0,0 +1,19 @@ +/src/* +!/src/routes +!/src/routes/** +!/src/locales +!/src/locales/** + +/node_modules +/.turbo +/tsconfig.json +/components.json +/global.d.ts +/types +/tsdown.config.ts +/vitest.config.ts +/tsconfig.json +/scripts +/config +/oxlint.config.ts +/dist/*.tsbuildinfo diff --git a/plugins/forum/global.d.ts b/plugins/forum/global.d.ts new file mode 100644 index 0000000000..c74dd53ee1 --- /dev/null +++ b/plugins/forum/global.d.ts @@ -0,0 +1,10 @@ +/// + +import core from "@vitnode/core/locales/en.json" with { type: "json" }; +import plugin from "./src/locales/en.json" with { type: "json" }; + +declare module "use-intl" { + interface AppConfig { + Messages: typeof plugin & typeof core; + } +} diff --git a/plugins/forum/oxlint.config.ts b/plugins/forum/oxlint.config.ts new file mode 100644 index 0000000000..92810844cf --- /dev/null +++ b/plugins/forum/oxlint.config.ts @@ -0,0 +1,8 @@ +import vitnode, { ignorePatterns } from "@vitnode/config/oxlint"; +import vitnodeReact from "@vitnode/config/oxlint.react"; +import { defineConfig } from "oxlint"; + +export default defineConfig({ + extends: [vitnode, vitnodeReact], + ignorePatterns, +}); diff --git a/plugins/forum/package.json b/plugins/forum/package.json new file mode 100644 index 0000000000..6d8280af66 --- /dev/null +++ b/plugins/forum/package.json @@ -0,0 +1,51 @@ +{ + "name": "@vitnode/forum", + "version": "2.0.0-canary.13", + "description": "Community forum for VitNode: structured forums and nested threads, built on the Content Engine.", + "license": "MIT", + "private": true, + "type": "module", + "exports": { + "./locales/*.json": "./src/locales/*.json", + "./*": { + "import": "./dist/src/*.js", + "types": "./dist/src/*.d.ts", + "default": "./dist/src/*.js" + } + }, + "scripts": { + "build:plugins": "vitnode build", + "dev": "vitnode dev", + "lint": "oxlint --type-aware", + "lint:fix": "oxlint --type-aware --fix", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@hono/zod-openapi": "^1.6.3", + "@tanstack/react-form": "^1.33.5", + "@tanstack/react-router": "^1.170.44", + "@vitnode/core": "workspace:*", + "drizzle-kit": "1.0.0-rc.4", + "drizzle-orm": "1.0.0-rc.4", + "hono": "^4.13.13", + "lucide-react": "^1.53.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "sonner": "^2.0.8", + "use-intl": "^4.14.9", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitnode/config": "workspace:*", + "cn": "^0.4.0", + "oxlint": "^1.87.0", + "oxlint-tsgolint": "^7.0.2003", + "postgres": "^3.4.9", + "tsdown": "^0.23.0", + "typescript": "^6.0.3", + "vitest": "^5.0.3" + } +} diff --git a/plugins/forum/src/admin/content.tsx b/plugins/forum/src/admin/content.tsx new file mode 100644 index 0000000000..2a6b0ed42d --- /dev/null +++ b/plugins/forum/src/admin/content.tsx @@ -0,0 +1,10 @@ +import type { ContentFrontendPluginSource } from "@vitnode/core/lib/plugin"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { forumForumsNav } from "./nav"; + +export const adminContent = { + pluginId: CONFIG_PLUGIN.pluginId, + containers: [forumForumsNav], +} satisfies ContentFrontendPluginSource; diff --git a/plugins/forum/src/admin/nav.tsx b/plugins/forum/src/admin/nav.tsx new file mode 100644 index 0000000000..04b2090f74 --- /dev/null +++ b/plugins/forum/src/admin/nav.tsx @@ -0,0 +1,16 @@ +import type { AdminNavPluginSource } from "@vitnode/core/lib/plugin"; + +import { FolderTreeIcon } from "lucide-react"; + +import { CONFIG_PLUGIN } from "@/const"; +import { forumContainers } from "@/content/forums"; + +export const forumForumsNav = { + definition: forumContainers, + icon: , +}; + +export const adminNav = { + pluginId: CONFIG_PLUGIN.pluginId, + containers: [forumForumsNav], +} satisfies AdminNavPluginSource; diff --git a/plugins/forum/src/api/admin-staff-permission.test.ts b/plugins/forum/src/api/admin-staff-permission.test.ts new file mode 100644 index 0000000000..10fe540ff2 --- /dev/null +++ b/plugins/forum/src/api/admin-staff-permission.test.ts @@ -0,0 +1,15 @@ +import { findAdminRoutesWithoutStaffPermission } from "@vitnode/core/api/lib/route"; +import { describe, expect, it } from "vitest"; + +import { forumApiPlugin } from "@/config.api"; + +describe("forum admin routes", () => { + it("all require a staff permission", () => { + const { hono } = forumApiPlugin(); + + expect(hono.routes.some(route => route.path.includes("/admin/"))).toBe( + true, + ); + expect(findAdminRoutesWithoutStaffPermission(hono)).toEqual([]); + }); +}); diff --git a/plugins/forum/src/api/modules/admin/admin.module.ts b/plugins/forum/src/api/modules/admin/admin.module.ts new file mode 100644 index 0000000000..60aad839f4 --- /dev/null +++ b/plugins/forum/src/api/modules/admin/admin.module.ts @@ -0,0 +1,20 @@ +import { buildModule } from "@vitnode/core/api/lib/module"; +import { buildContentAdminModule } from "@vitnode/core/content/server"; + +import { CONFIG_PLUGIN } from "@/const"; +import { forumContainers } from "@/content/forums"; +import { commentContent } from "@/database/comments"; +import { topicContent } from "@/database/topics"; + +export const adminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "admin", + routes: [], + modules: [ + buildContentAdminModule({ + pluginId: CONFIG_PLUGIN.pluginId, + containers: [forumContainers], + contentTypes: [topicContent, commentContent], + }), + ], +}); diff --git a/plugins/forum/src/api/modules/forum/forum.module.ts b/plugins/forum/src/api/modules/forum/forum.module.ts new file mode 100644 index 0000000000..2f7f2a0c9d --- /dev/null +++ b/plugins/forum/src/api/modules/forum/forum.module.ts @@ -0,0 +1,15 @@ +import { buildModule } from "@vitnode/core/api/lib/module"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { + forumStatsRoute, + topicSlugRoute, + topicStatsRoute, +} from "./stats.routes"; + +export const forumModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "forum", + routes: [forumStatsRoute, topicStatsRoute, topicSlugRoute], +}); diff --git a/plugins/forum/src/api/modules/forum/stats.routes.ts b/plugins/forum/src/api/modules/forum/stats.routes.ts new file mode 100644 index 0000000000..d68d2ecf84 --- /dev/null +++ b/plugins/forum/src/api/modules/forum/stats.routes.ts @@ -0,0 +1,248 @@ +import type { PgColumn } from "drizzle-orm/pg-core"; + +import { z } from "@hono/zod-openapi"; +import { setPageCacheTags } from "@vitnode/core/api/lib/page-cache"; +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { + contentContainerTag, + contentPublicListTag, +} from "@vitnode/core/content"; +import { + publicationColumns, + publishedCondition, +} from "@vitnode/core/content/server"; +import { and, count, desc, eq, inArray, max } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import { CONFIG_PLUGIN } from "@/const"; +import { forumContainers } from "@/content/forums"; +import { commentContent } from "@/database/comments"; +import { topicContent } from "@/database/topics"; + +export const FORUM_STATS_MAX_TOPICS = 50; + +const topicColumns = topicContent.columns as Record; +const commentColumns = commentContent.columns as Record; + +const publicTopic = () => + publishedCondition(publicationColumns(topicContent.definition, topicColumns)); + +const publicComment = () => + publishedCondition( + publicationColumns(commentContent.definition, commentColumns), + ); + +const iso = (value: Date | null | string | undefined): null | string => + value === null || value === undefined ? null : new Date(value).toISOString(); + +const statsTags = (): string[] => [ + contentPublicListTag(topicContent.definition.id), + contentPublicListTag(commentContent.definition.id), + contentContainerTag(forumContainers.id), +]; + +const json = (schema: T, description: string) => ({ + content: { "application/json": { schema } }, + description, +}); + +const zodLatestTopic = z.object({ + id: z.number(), + publishedAt: z.string().nullable(), + slug: z.string(), + title: z.string(), +}); + +export const forumStatsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/stats/forums", + description: + "Public topic and comment counts per forum, with each forum's newest public topic", + responses: { + 200: json( + z.object({ + items: z.array( + z.object({ + forumId: z.number(), + latestTopic: zodLatestTopic.nullable(), + commentCount: z.number(), + topicCount: z.number(), + }), + ), + }), + "Stats for every forum with at least one public topic", + ), + }, + }, + handler: async c => { + const db = c.get("db"); + const topics = topicContent.table; + const comments = commentContent.table; + + const [topicCounts, commentCounts, latest] = await Promise.all([ + db + .select({ forumId: topics.forum, value: count() }) + .from(topics) + .where(publicTopic()) + .groupBy(topics.forum), + db + .select({ forumId: topics.forum, value: count() }) + .from(comments) + .innerJoin(topics, eq(comments.topic, topics.id)) + .where(and(publicComment(), publicTopic())) + .groupBy(topics.forum), + db + .selectDistinctOn([topics.forum], { + forumId: topics.forum, + id: topics.id, + publishedAt: topics.publishedAt, + slug: topics.slug, + title: topics.title, + }) + .from(topics) + .where(publicTopic()) + .orderBy(topics.forum, desc(topics.publishedAt), desc(topics.id)), + ]); + + const commentCountOf = new Map( + commentCounts.map(row => [row.forumId, row.value]), + ); + const latestOf = new Map(latest.map(row => [row.forumId, row])); + + setPageCacheTags(c, statsTags()); + + return c.json( + { + items: topicCounts.map(row => { + const newest = latestOf.get(row.forumId); + + return { + forumId: row.forumId, + latestTopic: newest + ? { + id: newest.id, + publishedAt: iso(newest.publishedAt), + slug: newest.slug, + title: newest.title, + } + : null, + commentCount: commentCountOf.get(row.forumId) ?? 0, + topicCount: row.value, + }; + }), + }, + 200, + ); + }, +}); + +const TOPIC_IDS = new RegExp( + `^[1-9]\\d{0,15}(,[1-9]\\d{0,15}){0,${FORUM_STATS_MAX_TOPICS - 1}}$`, +); + +export const topicStatsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/stats/topics", + description: `Public comment count and last public comment time for up to ${FORUM_STATS_MAX_TOPICS} topics`, + request: { + query: z.object({ + ids: z.string().regex(TOPIC_IDS).openapi({ example: "1,2,3" }), + }), + }, + responses: { + 200: json( + z.object({ + items: z.array( + z.object({ + lastCommentAt: z.string().nullable(), + commentCount: z.number(), + topicId: z.number(), + }), + ), + }), + "Stats for the requested topics that have public comments", + ), + 400: { description: "`ids` is not a list of up to 50 topic ids" }, + }, + }, + handler: async c => { + const ids = [ + ...new Set(c.req.valid("query").ids.split(",").map(Number)), + ].filter(id => Number.isSafeInteger(id)); + const comments = commentContent.table; + const topics = topicContent.table; + + const rows = + ids.length === 0 + ? [] + : await c + .get("db") + .select({ + lastCommentAt: max(comments.publishedAt), + topicId: comments.topic, + value: count(), + }) + .from(comments) + .innerJoin(topics, eq(comments.topic, topics.id)) + .where( + and(inArray(comments.topic, ids), publicComment(), publicTopic()), + ) + .groupBy(comments.topic); + + setPageCacheTags(c, statsTags()); + + return c.json( + { + items: rows.map(row => ({ + lastCommentAt: iso(row.lastCommentAt), + commentCount: row.value, + topicId: row.topicId, + })), + }, + 200, + ); + }, +}); + +export const topicSlugRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/topics/{id}/slug", + description: + "The public slug of one topic, for building a comment permalink", + request: { + params: z.object({ + id: z.coerce.number().int().positive().openapi({ example: 1 }), + }), + }, + responses: { + 200: json( + z.object({ id: z.number(), slug: z.string() }), + "The topic's slug", + ), + 404: { description: "No public topic with this id" }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const topics = topicContent.table; + + const [row] = await c + .get("db") + .select({ id: topics.id, slug: topics.slug }) + .from(topics) + .where(and(eq(topics.id, id), publicTopic())) + .limit(1); + + if (!row) throw new HTTPException(404, { message: "Topic not found." }); + + setPageCacheTags(c, [contentPublicListTag(topicContent.definition.id)]); + + return c.json({ id: row.id, slug: row.slug }, 200); + }, +}); diff --git a/plugins/forum/src/config.api.ts b/plugins/forum/src/config.api.ts new file mode 100644 index 0000000000..f0fc8ae60d --- /dev/null +++ b/plugins/forum/src/config.api.ts @@ -0,0 +1,30 @@ +import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin"; + +import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; +import { buildContentPublicModule } from "@vitnode/core/content/server"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { forumModule } from "@/api/modules/forum/forum.module"; +import { CONFIG_PLUGIN } from "@/const"; +import { forumContainers } from "@/content/forums"; +import { commentContent } from "@/database/comments"; +import { topicContent } from "@/database/topics"; + +export const forumApiPlugin = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + navigation: [{ href: "/forum", icon: "messages-square", id: "forum" }], + modules: [ + adminModule, + forumModule, + buildContentPublicModule({ + pluginId: CONFIG_PLUGIN.pluginId, + containers: [forumContainers], + contentTypes: [topicContent, commentContent], + }), + ], + }); + +export type VitNodeApiPlugin = ApiPluginContract< + ReturnType +>; diff --git a/plugins/forum/src/config.tsx b/plugins/forum/src/config.tsx new file mode 100644 index 0000000000..071db6fa70 --- /dev/null +++ b/plugins/forum/src/config.tsx @@ -0,0 +1,16 @@ +import { buildPlugin } from "@vitnode/core/lib/plugin"; + +import { CONFIG_PLUGIN } from "@/const"; + +import messages from "./locales"; +import { routes } from "./routes"; + +export const forumPlugin = () => + buildPlugin({ + ...CONFIG_PLUGIN, + localeFiles: { + en: "@vitnode/forum/locales/en.json", + }, + messages, + routes, + }); diff --git a/plugins/forum/src/const.ts b/plugins/forum/src/const.ts new file mode 100644 index 0000000000..016b8ead08 --- /dev/null +++ b/plugins/forum/src/const.ts @@ -0,0 +1 @@ +export const CONFIG_PLUGIN = { pluginId: "@vitnode/forum" as const }; diff --git a/plugins/forum/src/content.ts b/plugins/forum/src/content.ts new file mode 100644 index 0000000000..107a4060a8 --- /dev/null +++ b/plugins/forum/src/content.ts @@ -0,0 +1,4 @@ +import { commentContentType } from "@/content/comment"; +import { topicContentType } from "@/content/topic"; + +export const contentTypes = [topicContentType, commentContentType]; diff --git a/plugins/forum/src/content/comment.ts b/plugins/forum/src/content/comment.ts new file mode 100644 index 0000000000..a3d20b2b34 --- /dev/null +++ b/plugins/forum/src/content/comment.ts @@ -0,0 +1,70 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +import { topicContentType } from "@/content/topic"; + +export const FORUM_COMMENT_MAX_DEPTH = 6; + +export const commentContentType = defineContentType({ + id: "forum.comment", + tableName: "forum_comments", + + fields: { + slug: field.slug({ generated: true }), + body: field.richText({ required: true }), + author: field.user(), + topic: field.relation({ + target: () => topicContentType, + required: true, + container: true, + onDelete: "cascade", + }), + parent: field.relation({ + self: true, + nullable: true, + onDelete: "set null", + }), + }, + + publication: true, + visibility: true, + editorial: { revisions: { retention: 20 } }, + + indexes: [{ on: ["topic", "parent", "createdAt"] }], + + publicApi: { + path: "comments", + fields: [ + "id", + "slug", + "body", + "topic", + "parent", + "publishedAt", + "createdAt", + "updatedAt", + ], + filterableFields: ["topic"], + people: ["author"], + }, + + publicWrite: { + ownership: { field: "author" }, + create: { fields: ["body", "topic", "parent"], status: "published" }, + update: { fields: ["body"] }, + }, + + search: { + titleField: "topic", + contentFields: ["body"], + authorField: "author", + pathTemplate: "/forum/comments/{slug}", + }, + + thread: { + parent: "parent", + root: "topic", + maxDepth: FORUM_COMMENT_MAX_DEPTH, + }, + + admin: { titleField: "slug" }, +}); diff --git a/plugins/forum/src/content/forums.ts b/plugins/forum/src/content/forums.ts new file mode 100644 index 0000000000..34912a67bb --- /dev/null +++ b/plugins/forum/src/content/forums.ts @@ -0,0 +1,28 @@ +import { defineContentContainer, field } from "@vitnode/core/content"; + +export const forumContainers = defineContentContainer({ + id: "forum.forums", + + fields: { + description: field.textarea({ maxLength: 500, nullable: true }), + icon: field.text({ maxLength: 64, nullable: true }), + }, + + hierarchy: { maxDepth: 4 }, + ordering: { mode: "manual" }, + + policies: { + defaults: { + publish: "immediate", + edits: "immediate", + authorEdit: "always", + }, + }, + + publicApi: { + path: "forums", + fields: ["description", "icon"], + }, + + admin: { path: "forum/forums" }, +}); diff --git a/plugins/forum/src/content/topic.ts b/plugins/forum/src/content/topic.ts new file mode 100644 index 0000000000..16cb7d6bf2 --- /dev/null +++ b/plugins/forum/src/content/topic.ts @@ -0,0 +1,62 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +import { forumContainers } from "@/content/forums"; + +export const topicContentType = defineContentType({ + id: "forum.topic", + tableName: "forum_topics", + + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + body: field.richText({ required: true }), + author: field.user(), + forum: field.container({ definition: forumContainers, required: true }), + }, + + publication: true, + visibility: true, + editorial: { revisions: { retention: 20 } }, + + indexes: [{ on: ["forum", "publishedAt"] }], + + publicApi: { + path: "topics", + fields: [ + "id", + "title", + "slug", + "body", + "forum", + "publishedAt", + "createdAt", + "updatedAt", + ], + filterableFields: ["forum"], + orderableFields: ["publishedAt", "updatedAt"], + people: ["author"], + }, + + publicWrite: { + ownership: { field: "author" }, + create: { fields: ["title", "body", "forum"], status: "published" }, + update: { fields: ["title", "body"] }, + }, + + delivery: { + path: "/forum/topics/:slug", + redirects: true, + seo: { titleField: "title", descriptionField: "body" }, + sitemap: { changeFrequency: "daily", priority: 0.6 }, + }, + + search: { + titleField: "title", + contentFields: ["body"], + authorField: "author", + pathTemplate: "/forum/topics/{slug}", + context: { field: "forum", pathTemplate: "/forum/{slug}" }, + }, + + admin: { titleField: "title" }, +}); diff --git a/plugins/forum/src/database/comments.ts b/plugins/forum/src/database/comments.ts new file mode 100644 index 0000000000..17b9b01cec --- /dev/null +++ b/plugins/forum/src/database/comments.ts @@ -0,0 +1,13 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { commentContentType } from "@/content/comment"; + +import { forum_topics } from "./topics"; + +export const commentContent = createContentModel(commentContentType, { + // `parent` is absent on purpose: a self-relation is resolved by the engine + // from the table it is building. + references: { topic: () => forum_topics.id }, +}); + +export const forum_comments = commentContent.table; diff --git a/plugins/forum/src/database/topics.ts b/plugins/forum/src/database/topics.ts new file mode 100644 index 0000000000..bd2196e007 --- /dev/null +++ b/plugins/forum/src/database/topics.ts @@ -0,0 +1,7 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { topicContentType } from "@/content/topic"; + +export const topicContent = createContentModel(topicContentType); + +export const forum_topics = topicContent.table; diff --git a/plugins/forum/src/locales/en.json b/plugins/forum/src/locales/en.json new file mode 100644 index 0000000000..34ef1deebf --- /dev/null +++ b/plugins/forum/src/locales/en.json @@ -0,0 +1,309 @@ +{ + "@vitnode/forum": { + "title": "Forum", + "navigation": { + "forum": { + "title": "Forum", + "description": "Topics, comments and the occasional heated debate about tabs versus spaces." + } + }, + "containers": { + "forums": { + "label": "{count, plural, one {Forum} other {Forums}}", + "title": "Forums", + "desc": "The forum tree members post their topics into. Drag to reorder or nest forums, and set who publishes straight away.", + "fields": { + "description": "Description", + "icon": "Icon" + } + } + }, + "content": { + "topic": { + "label": "{count, plural, one {Topic} other {Topics}}", + "title": "Topics", + "desc": "Conversations started by members. Each one lives in a forum and collects comments.", + "fields": { + "title": "Title", + "slug": "Slug", + "body": "Message", + "author": "Author", + "forum": "Forum", + "publishedAt": "Published", + "updatedAt": "Updated", + "createdAt": "Created" + } + }, + "comment": { + "label": "{count, plural, one {Comment} other {Comments}}", + "title": "Comments", + "desc": "Comments on topics and replies to them, nested up to six levels deep.", + "fields": { + "slug": "Permalink", + "body": "Message", + "author": "Author", + "topic": "Topic", + "parent": "In reply to", + "publishedAt": "Published", + "updatedAt": "Updated", + "createdAt": "Created" + } + } + }, + "forums": { + "title": "Forum", + "desc": "Pull up a chair. Ask questions, share what you built and help each other out.", + "tree": "Forums", + "new_topic": "New topic", + "empty": { + "title": "No forums yet", + "desc": "An administrator has not opened any forums. The chairs are stacked, the coffee is brewing - check back soon.", + "home": "Back to home" + }, + "stats": { + "topics": "Topics", + "comments": "Comments" + }, + "latest": "Latest topic", + "no_topics_yet": "No topics yet - be the first!", + "deleted_user": "Deleted user", + "not_found": { + "title": "Forum not found", + "desc": "This forum does not exist, or it moved somewhere else.", + "back": "All forums" + }, + "forum_desc_fallback": "Topics and comments in {name}.", + "subforums": "Subforums", + "topics": "Topics", + "empty_forum": { + "title": "Quiet in here", + "desc": "Nobody has started a topic in this forum yet. Break the ice!" + }, + "pagination": "Topic pages", + "newest": "Newest topics", + "older": "Older topics", + "comment_count": "{count, plural, =0 {No comments} one {# comment} other {# comments}}", + "last_comment": "Last comment", + "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.", + "invalid": "Some of what you sent is not valid. Check the form and try again.", + "not_found": "This no longer exists, or you cannot see it.", + "unauthenticated": "Your session ended. Sign in and try again.", + "unique": "A topic with this title already exists. Try a slightly different title.", + "issues": { + "edit_window_closed": "The time to edit this has run out. Ask a moderator if something needs fixing.", + "moderators_only": "Only moderators can post in this forum.", + "not_writable": "This field cannot be changed.", + "unavailable": "This is not available.", + "forum_unavailable": "This forum is not available. Pick another one.", + "parent_unavailable": "That comment can no longer be answered. Reply higher up in the thread.", + "topic_unavailable": "This topic no longer accepts comments." + } + } + }, + "topics": { + "form": { + "title": { + "label": "Title", + "desc": "A short summary people will want to click.", + "min": "Use at least 3 characters.", + "max": "Keep it under 200 characters." + }, + "body": { + "label": "Message", + "desc": "Say what is on your mind. Formatting, links and images are welcome.", + "required": "Write a message first." + }, + "forum": { + "label": "Forum", + "desc": "Where your topic will live.", + "placeholder": "Choose a forum", + "required": "Choose a forum." + } + }, + "new": { + "title": "Start a topic", + "desc": "Ask a question, share an idea or start a friendly argument.", + "submit": "Post topic", + "no_forums": { + "title": "No forums yet", + "desc": "There is nowhere to post a topic until an administrator opens a forum.", + "action": "Back to the forum" + } + }, + "edit": { + "title": "Edit topic", + "desc": "Fix a typo or add what you forgot. We all do it.", + "submit": "Save changes", + "view": "View topic", + "success": { + "title": "Topic saved", + "desc": "\"{title}\" is up to date." + }, + "pending": { + "title": "Sent for approval", + "desc": "A moderator will review your changes. Readers see the published version until then." + }, + "conflict": { + "title": "This topic changed", + "desc": "Someone saved a newer version while you were editing. Reload to see it, then make your changes again.", + "reload": "Reload" + } + }, + "state": { + "back": "Back to the forum", + "unauthenticated": { + "title": "Sign in to edit", + "desc": "Only the author and moderators can edit a topic.", + "action": "Sign in" + }, + "forbidden": { + "title": "Not your topic", + "desc": "Only the author and moderators can edit this topic." + }, + "not_found": { + "title": "Topic not found", + "desc": "This topic does not exist, or you cannot see it." + }, + "window_closed": { + "title": "Editing has closed", + "desc": "The time to edit this topic has run out. Ask a moderator if something needs fixing." + } + }, + "approval": { + "rejected": { + "title": "A moderator declined this", + "desc": "Your submission was not approved. You can edit it and send it again.", + "reason": "Reason: {reason}" + }, + "pending_edit": { + "title": "Your changes are waiting for approval", + "desc": "Readers still see the published version. You are looking at your proposed changes." + }, + "pending_create": { + "title": "Waiting for a moderator", + "desc": "Your topic goes live as soon as a moderator approves it. You can still polish it here.", + "link": "See all your posts awaiting approval." + } + }, + "detail": { + "edit": "Edit", + "new": "New topic", + "posted": "Posted", + "actions": "Topic actions", + "created": { + "title": "Topic posted", + "desc": "Your topic is live. Now we wait for the comments to roll in." + } + } + }, + "comments": { + "title": "{count, plural, =0 {Comments} one {# comment} other {# comments}}", + "empty": { + "title": "No comments yet", + "desc": "Be the first to comment. No pressure." + }, + "truncated": "This thread is very long, so some deeper comments are not shown here.", + "load_more": "Load more comments", + "load_failed": { + "title": "Could not load comments", + "desc": "Please check your connection and try again." + }, + "compose": { + "title": "Join the conversation", + "guest": "You need an account to comment.", + "sign_in": "Sign in to comment" + }, + "form": { + "body_label": "Your comment", + "edit_label": "Edit your comment", + "answer_label": "Reply to {name}", + "submit": "Post comment", + "save": "Save", + "cancel": "Cancel", + "body": { + "required": "Write a comment first." + } + }, + "pending": { + "title": "Your comment is waiting for a moderator", + "desc": "It will appear in the thread once it is approved." + }, + "created": { + "title": "Comment posted", + "desc": "Your comment is in the thread." + }, + "edited": { + "title": "Comment saved", + "desc": "Your changes are live." + }, + "conflict": { + "title": "This comment changed", + "desc": "A newer version was saved while you were editing. Reload the page and try again." + }, + "permalink": "Link to this comment", + "edited_label": "edited", + "reply": "Reply", + "edit": "Edit", + "someone": "this comment", + "removed": "This comment was removed.", + "show_answers": "{count, plural, one {Show # reply} other {Show # replies}}", + "hide_answers": "{count, plural, one {Hide # reply} other {Hide # replies}}" + }, + "pending": { + "title": "My posts awaiting approval", + "desc": "Everything you posted that a moderator has reviewed or is about to.", + "untitled_comment": "A comment", + "untitled_topic": "A topic", + "status": { + "pending": "Waiting", + "approved": "Approved", + "rejected": "Declined" + }, + "type": { + "topic": "Topic", + "comment": "Comment" + }, + "kind": { + "create": "new", + "edit": "edit" + }, + "submitted": "Submitted", + "reason": "Reason: {reason}", + "no_reason": "The moderator did not leave a reason.", + "empty": { + "title": "Nothing waiting", + "desc": "None of your posts need a moderator. Lovely.", + "action": "Back to the forum" + }, + "pagination": "Submission pages", + "newer": "Newer", + "older": "Older" + } + }, + "@vitnode/forum:topic": "Topics", + "@vitnode/forum:topic:can_view": "View topics", + "@vitnode/forum:topic:can_create": "Create topics", + "@vitnode/forum:topic:can_edit": "Edit topics", + "@vitnode/forum:topic:can_delete": "Delete topics", + "@vitnode/forum:topic:can_publish": "Publish and unpublish topics", + "@vitnode/forum:topic:can_hide": "Hide and unhide topics", + "@vitnode/forum:topic:can_restore": "Restore an earlier version of a topic", + "@vitnode/forum:topic:can_approve": "Approve or decline topics waiting for review", + "@vitnode/forum:comment": "Comments", + "@vitnode/forum:comment:can_view": "View comments", + "@vitnode/forum:comment:can_create": "Create comments", + "@vitnode/forum:comment:can_edit": "Edit comments", + "@vitnode/forum:comment:can_delete": "Delete comments", + "@vitnode/forum:comment:can_publish": "Publish and unpublish comments", + "@vitnode/forum:comment:can_hide": "Hide and unhide comments", + "@vitnode/forum:comment:can_restore": "Restore an earlier version of a comment", + "@vitnode/forum:comment:can_approve": "Approve or decline comments waiting for review", + "@vitnode/forum:forum_forums": "Forums", + "@vitnode/forum:forum_forums:can_view": "View forums", + "@vitnode/forum:forum_forums:can_create": "Create forums", + "@vitnode/forum:forum_forums:can_edit": "Edit, move and set policies of forums", + "@vitnode/forum:forum_forums:can_delete": "Delete forums" +} diff --git a/plugins/forum/src/locales/index.ts b/plugins/forum/src/locales/index.ts new file mode 100644 index 0000000000..6e61a48826 --- /dev/null +++ b/plugins/forum/src/locales/index.ts @@ -0,0 +1,11 @@ +import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types"; + +/** + * Every language this plugin ships. Add a file next to this one and a line + * here to add another; apps pick it up with no copy step. + */ +const messages: LocaleMessagesMap = { + en: async () => await import("./en.json", { with: { type: "json" } }), +}; + +export default messages; diff --git a/plugins/forum/src/pages/comment-composer.tsx b/plugins/forum/src/pages/comment-composer.tsx new file mode 100644 index 0000000000..96ad12752b --- /dev/null +++ b/plugins/forum/src/pages/comment-composer.tsx @@ -0,0 +1,154 @@ +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 { + emptyRichTextDocument, + isRichTextEmpty, + type RichTextDocument, + zodRichTextDocument, +} from "@vitnode/core/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { useForumRejection } from "./forum-errors"; + +const COMMENT_FORM_FIELDS = ["body"] as const; + +export type CommentComposerProps = { + label: string; + onCancel?: () => void; + onDone: (outcome: { pending: boolean }) => void; +} & ( + | { + commentId: number; + defaultBody: RichTextDocument; + expectedVersion: number; + mode: "edit"; + } + | { mode: "create"; parentId: null | number; topicId: number } +); + +const useCommentSchema = (defaultBody: RichTextDocument) => { + const t = useTranslations("@vitnode/forum.comments.form"); + + return z.object({ + body: zodRichTextDocument + .refine(doc => !isRichTextEmpty(doc), t("body.required")) + .default(defaultBody), + }); +}; + +type CommentSchema = ReturnType; + +const CommentComposer = (props: CommentComposerProps) => { + const { label, onCancel, onDone } = props; + const t = useTranslations("@vitnode/forum.comments"); + const rejectComment = useForumRejection(COMMENT_FORM_FIELDS); + const formSchema = useCommentSchema( + props.mode === "edit" ? props.defaultBody : emptyRichTextDocument(), + ); + + const announce = (pending: boolean, edited: boolean) => { + if (pending) { + toast.info(t("pending.title"), { description: t("pending.desc") }); + } else if (edited) { + toast.success(t("edited.title"), { description: t("edited.desc") }); + } else { + toast.success(t("created.title"), { description: t("created.desc") }); + } + onDone({ pending }); + }; + + const onSubmit: AutoFormOnSubmit = async ({ body }, form) => { + if (props.mode === "edit") { + const res = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "patch", + module: "content/comments", + path: "/{id}", + args: { + params: { id: props.commentId }, + body: { values: { body }, expectedVersion: props.expectedVersion }, + }, + }); + + if (res.status === 200) { + const updated = await res.json(); + announce(updated.approval?.status === "pending", true); + + return; + } + + if (res.status === 409) { + toast.error(t("conflict.title"), { description: t("conflict.desc") }); + + return; + } + + await rejectComment(res, form); + + return; + } + + const res = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "content/comments", + path: "/", + args: { + body: { + values: { body, parent: props.parentId, topic: props.topicId }, + }, + }, + }); + + if (res.status === 201) { + const record = await res.json(); + form.reset(); + announce(record.approval?.status === "pending", false); + + return; + } + + await rejectComment(res, form); + }; + + return ( + ( + + ), + }, + ]} + formSchema={formSchema} + layout={fields => <>{fields.body}} + onSubmit={onSubmit} + > +
    + + {props.mode === "edit" ? t("form.save") : t("form.submit")} + + {onCancel ? ( + + ) : null} +
    +
    + ); +}; + +export default CommentComposer; diff --git a/plugins/forum/src/pages/comment-redirect-page.tsx b/plugins/forum/src/pages/comment-redirect-page.tsx new file mode 100644 index 0000000000..8262ea48d9 --- /dev/null +++ b/plugins/forum/src/pages/comment-redirect-page.tsx @@ -0,0 +1,62 @@ +import { redirect } from "@tanstack/react-router"; +import { definePluginRoute } from "@vitnode/core/routing"; +import { contentPageItem } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { commentAnchorId, topicHref } from "./forum-links"; +import { zodRelationId } from "./thread-data"; + +const zodCommentLink = z.object({ slug: z.string(), topic: zodRelationId }); + +/** A comment has no page of its own: its permalink is its place in the thread. */ +const resolveComment = async (slug: string): Promise => { + const comment = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/comments", + path: "/{slug}", + args: { params: { slug: encodeURIComponent(slug) } }, + }); + + if (comment.status !== 200 && comment.status !== 404) { + throw new Error(`Resolving the comment "${slug}" failed.`); + } + + const record = contentPageItem( + comment.status === 200 ? zodCommentLink.parse(await comment.json()) : null, + ); + + const topic = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "forum", + path: "/topics/{id}/slug", + args: { params: { id: record.topic } }, + }); + + const target = contentPageItem( + topic.status === 200 ? await topic.json() : null, + ); + + // oxlint-disable-next-line typescript/only-throw-error + throw redirect({ + href: `${topicHref(target.slug)}#${commentAnchorId(record.slug)}`, + statusCode: 302, + }); +}; + +export const route = definePluginRoute({ + load: async ({ params }) => await resolveComment(params.slug), + + head: ({ t }) => ({ + robots: "noindex, nofollow", + title: t("@vitnode/forum.comments.permalink"), + }), +}); + +const CommentRedirectPage = () => null; + +export default CommentRedirectPage; diff --git a/plugins/forum/src/pages/forum-data.ts b/plugins/forum/src/pages/forum-data.ts new file mode 100644 index 0000000000..b6a9d61021 --- /dev/null +++ b/plugins/forum/src/pages/forum-data.ts @@ -0,0 +1,96 @@ +import { zodContentPublicPerson } from "@vitnode/core/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; + +export const fetchForums = async () => { + const res = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/forums", + path: "/", + args: { query: {} }, + }); + + if (res.status !== 200) throw new Error("Loading the forums failed."); + + const { items } = await res.json(); + + return items; +}; + +export type Forum = Awaited>[number]; + +export const fetchForumStats = async (): Promise => { + const res = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "forum", + path: "/stats/forums", + }); + + if (res.status !== 200) return []; + + const { items } = await res.json(); + + return items; +}; + +export interface ForumStats { + commentCount: number; + forumId: number; + latestTopic: null | { + id: number; + publishedAt: null | string; + slug: string; + title: string; + }; + topicCount: number; +} + +export const fetchTopicStats = async ( + ids: readonly number[], +): Promise => { + const unique = [...new Set(ids)].slice(0, 50); + if (unique.length === 0) return []; + + const res = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "forum", + path: "/stats/topics", + args: { query: { ids: unique.join(",") } }, + }); + + if (res.status !== 200) return []; + + const { items } = await res.json(); + + return items; +}; + +export interface TopicStats { + commentCount: number; + lastCommentAt: null | string; + topicId: number; +} + +export const zodPerson = zodContentPublicPerson.nullable().catch(null); + +export type ForumPerson = z.infer; + +export const zodTopicRow = z.object({ + author: zodPerson, + id: z.number(), + publishedAt: z.string().nullable(), + slug: z.string(), + title: z.string(), +}); + +export type TopicRow = z.infer; + +export const indexBy = ( + items: readonly TItem[], + key: (item: TItem) => TKey, +): Map => new Map(items.map(item => [key(item), item])); diff --git a/plugins/forum/src/pages/forum-errors.ts b/plugins/forum/src/pages/forum-errors.ts new file mode 100644 index 0000000000..4d74bd6781 --- /dev/null +++ b/plugins/forum/src/pages/forum-errors.ts @@ -0,0 +1,86 @@ +import type { ContentPublicWriteRejection } from "@vitnode/core/content"; + +import { setFormFieldError } from "@vitnode/core/components/form/auto-form"; +import { parseContentPublicWriteRejection } from "@vitnode/core/content"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +type FormApi = Parameters[0]; + +const ISSUE_MESSAGE_KEYS = { + edit_window_closed: "issues.edit_window_closed", + moderators_only: "issues.moderators_only", + not_writable: "issues.not_writable", + unavailable: "issues.unavailable", +} as const; + +const FIELD_ISSUE_MESSAGE_KEYS = { + forum: "issues.forum_unavailable", + parent: "issues.parent_unavailable", + topic: "issues.topic_unavailable", +} as const; + +const REJECTION_MESSAGE_KEYS = { + CONTENT_FORBIDDEN: "forbidden", + CONTENT_INVALID_INPUT: "invalid", + CONTENT_NOT_FOUND: "not_found", + CONTENT_UNAUTHENTICATED: "unauthenticated", +} as const satisfies Record; + +const isKnownIssueCode = ( + code: string, +): code is keyof typeof ISSUE_MESSAGE_KEYS => code in ISSUE_MESSAGE_KEYS; + +const isPlacementField = ( + field: null | string, +): field is keyof typeof FIELD_ISSUE_MESSAGE_KEYS => + field !== null && field in FIELD_ISSUE_MESSAGE_KEYS; + +/** + * Turns a refused public write into field errors where the form has the field, + * and one toast for everything else - a closed edit window, a moderators-only + * forum, a comment that can no longer be answered. + */ +export const useForumRejection = (formFields: readonly string[]) => { + const t = useTranslations("@vitnode/forum.forums.errors"); + + const messageOf = (code: string, field: null | string) => { + if (isKnownIssueCode(code) && code !== "unavailable") { + return t(ISSUE_MESSAGE_KEYS[code]); + } + if (isPlacementField(field)) return t(FIELD_ISSUE_MESSAGE_KEYS[field]); + + return t(isKnownIssueCode(code) ? ISSUE_MESSAGE_KEYS[code] : "invalid"); + }; + + return async (response: Response, form?: FormApi) => { + const rejection = parseContentPublicWriteRejection( + await response.json().catch(() => null), + ); + + const unmatched = (rejection?.issues ?? []).filter(issue => { + if (!form || issue.field === null || !formFields.includes(issue.field)) { + return true; + } + setFormFieldError(form, issue.field, messageOf(issue.code, issue.field)); + + return false; + }); + + if (rejection?.code === "CONTENT_INVALID_INPUT" && unmatched.length === 0) { + return; + } + + const [first] = unmatched; + + toast.error(t("title"), { + description: rejection + ? first && + (rejection.code === "CONTENT_INVALID_INPUT" || + isKnownIssueCode(first.code)) + ? messageOf(first.code, first.field) + : t(REJECTION_MESSAGE_KEYS[rejection.code]) + : t("unknown"), + }); + }; +}; diff --git a/plugins/forum/src/pages/forum-links.ts b/plugins/forum/src/pages/forum-links.ts new file mode 100644 index 0000000000..1d07ad39e9 --- /dev/null +++ b/plugins/forum/src/pages/forum-links.ts @@ -0,0 +1,98 @@ +export const FORUM_HREF = "/forum"; +export const FORUM_PENDING_HREF = "/forum/me/pending"; + +const CURSOR = /^[A-Za-z0-9_-]{1,512}$/; + +const positiveId = (raw: unknown): number | undefined => { + const value = + typeof raw === "number" + ? raw + : typeof raw === "string" && /^[1-9]\d{0,15}$/.test(raw) + ? Number(raw) + : Number.NaN; + + return Number.isSafeInteger(value) && value > 0 ? value : undefined; +}; + +const isTrue = (raw: unknown): boolean => + raw === true || raw === "true" || raw === 1 || raw === "1"; + +export interface ForumSearch { + cursor?: string; +} + +export const forumSearch = (input: Record): ForumSearch => + typeof input.cursor === "string" && CURSOR.test(input.cursor) + ? { cursor: input.cursor } + : {}; + +export const forumHref = (slug: string, { cursor }: ForumSearch = {}): string => + `${FORUM_HREF}/${encodeURIComponent(slug)}${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`; + +export interface TopicSearch { + created?: true; +} + +export const topicSearch = (input: Record): TopicSearch => + isTrue(input.created) ? { created: true } : {}; + +export const topicHref = ( + slug: string, + { created }: TopicSearch = {}, +): string => + `${FORUM_HREF}/topics/${encodeURIComponent(slug)}${created ? "?created=true" : ""}`; + +export const commentAnchorId = (commentSlug: string): string => + `comment-${commentSlug}`; + +export const commentPermalink = (commentSlug: string): string => + `${FORUM_HREF}/comments/${encodeURIComponent(commentSlug)}`; + +export interface TopicNewSearch { + forum?: number; +} + +export const topicNewSearch = ( + input: Record, +): TopicNewSearch => { + const forum = positiveId(input.forum); + + return forum === undefined ? {} : { forum }; +}; + +export const topicNewHref = (forum?: number): string => + forum === undefined + ? `${FORUM_HREF}/create/topic` + : `${FORUM_HREF}/create/topic?forum=${forum}`; + +export interface TopicEditSearch { + submitted?: true; +} + +export const topicEditSearch = ( + input: Record, +): TopicEditSearch => (isTrue(input.submitted) ? { submitted: true } : {}); + +export const topicEditHref = ( + id: number | string, + { submitted }: TopicEditSearch = {}, +): string => + `${FORUM_HREF}/topics/${encodeURIComponent(String(id))}/edit${submitted ? "?submitted=1" : ""}`; + +export interface PendingSearch { + page?: number; +} + +export const pendingSearch = ( + input: Record, +): PendingSearch => { + const page = positiveId(input.page); + + return page === undefined || page === 1 ? {} : { page }; +}; + +export const pendingHref = (page = 1): string => + page <= 1 ? FORUM_PENDING_HREF : `${FORUM_PENDING_HREF}?page=${page}`; + +export const loginHref = (returnTo: string): string => + `/login?returnTo=${encodeURIComponent(returnTo)}`; diff --git a/plugins/forum/src/pages/forum-list.tsx b/plugins/forum/src/pages/forum-list.tsx new file mode 100644 index 0000000000..de5ea4946a --- /dev/null +++ b/plugins/forum/src/pages/forum-list.tsx @@ -0,0 +1,114 @@ +import type { ContentContainerTreeNode } from "@vitnode/core/content"; + +import { useFormatter, useTranslations } from "use-intl"; + +import type { Forum, ForumStats } from "./forum-data"; + +import { forumHref, topicHref } from "./forum-links"; +import { ForumIcon, TimeAgo } from "./forum-ui"; + +const ForumRow = ({ + forum, + stats, +}: { + forum: Forum; + stats: ForumStats | undefined; +}) => { + const t = useTranslations("@vitnode/forum.forums"); + const format = useFormatter(); + const latest = stats?.latestTopic ?? null; + + return ( +
    +
    + +
    +

    + + {forum.name} + +

    + {forum.metadata.description ? ( +

    + {forum.metadata.description} +

    + ) : null} +
    +
    + +
    +
    +
    {t("stats.topics")}
    +
    + {format.number(stats?.topicCount ?? 0)} +
    +
    +
    +
    {t("stats.comments")}
    +
    + {format.number(stats?.commentCount ?? 0)} +
    +
    +
    + +
    + {latest ? ( + <> + {t("latest")} + + {latest.title} + + {latest.publishedAt ? ( + + + + ) : null} + + ) : ( + {t("no_topics_yet")} + )} +
    +
    + ); +}; + +const ForumListItems = ({ + nodes, + stats, +}: { + nodes: ContentContainerTreeNode[]; + stats: ReadonlyMap; +}) => ( +
      + {nodes.map(node => ( +
    • + + {node.children.length > 0 ? ( +
      + +
      + ) : null} +
    • + ))} +
    +); + +export const ForumList = ({ + label, + nodes, + stats, +}: { + label: string; + nodes: ContentContainerTreeNode[]; + stats: ReadonlyMap; +}) => ( + +); diff --git a/plugins/forum/src/pages/forum-page.tsx b/plugins/forum/src/pages/forum-page.tsx new file mode 100644 index 0000000000..e78fcc5eb1 --- /dev/null +++ b/plugins/forum/src/pages/forum-page.tsx @@ -0,0 +1,351 @@ +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@vitnode/core/components/ui/breadcrumb"; +import { buttonVariants } from "@vitnode/core/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@vitnode/core/components/ui/empty"; +import { + definePluginRoute, + type PluginRoutePageProps, +} from "@vitnode/core/routing"; +import { contentPageItem } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { + MessageSquareIcon, + MessagesSquareIcon, + PenSquareIcon, + SearchXIcon, +} from "lucide-react"; +import { Fragment } from "react"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; + +import type { TopicRow, TopicStats } from "./forum-data"; +import type { ForumSearch } from "./forum-links"; + +import { + fetchForumStats, + fetchTopicStats, + indexBy, + zodTopicRow, +} from "./forum-data"; +import { FORUM_HREF, forumHref, topicHref, topicNewHref } from "./forum-links"; +import { ForumList } from "./forum-list"; +import { + ForumIcon, + ForumShell, + PersonAvatar, + PersonName, + TimeAgo, +} from "./forum-ui"; + +const PAGE_SIZE = 25; + +const loadForum = async (slug: string, { cursor }: ForumSearch) => { + const detail = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/forums", + path: "/{idOrSlug}", + args: { params: { idOrSlug: encodeURIComponent(slug) } }, + }); + + if (detail.status !== 200 && detail.status !== 404) { + throw new Error("Loading the forum failed."); + } + + const { ancestors, children, container } = contentPageItem( + detail.status === 200 ? await detail.json() : null, + ); + + // Spread rather than written inline: the generated list query types its + // filters loosely, so an inline key reads as an excess property. + const filters = { forum: String(container.id) }; + + const [list, forumStats] = await Promise.all([ + fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/topics", + path: "/", + args: { + query: { + ...filters, + first: String(PAGE_SIZE), + ...(cursor ? { cursor } : {}), + }, + }, + }), + children.length > 0 ? fetchForumStats() : Promise.resolve([]), + ]); + + if (list.status !== 200) throw new Error("Loading the topics failed."); + + const { edges, pageInfo } = await list.json(); + const topics = z.array(zodTopicRow).parse(edges); + + return { + ancestors, + children, + container, + forumStats, + isFirstPage: cursor === undefined, + nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null, + topicStats: await fetchTopicStats(topics.map(topic => topic.id)), + topics, + }; +}; + +type ForumPageData = Awaited>; + +const ForumNotFound = () => { + const t = useTranslations("@vitnode/forum.forums.not_found"); + + return ( +
    + + + + + }>{t("title")} + {t("desc")} + + + + {t("back")} + + + +
    + ); +}; + +const ForumsCrumb = () => { + const t = useTranslations("@vitnode/forum.forums"); + + return <>{t("title")}; +}; + +export const route = definePluginRoute({ + load: async ({ params, search }) => await loadForum(params.slug, search), + + head: ({ loaderData, t }) => ({ + description: + loaderData?.container.metadata.description ?? + t("@vitnode/forum.forums.forum_desc_fallback", { + name: loaderData?.container.name ?? "", + }), + title: loaderData?.container.name, + }), + + breadcrumb: { + group: ({ loaderData }) => ( + <> + + + + + + {loaderData.ancestors.map(ancestor => ( + + + + + {ancestor.name} + + + + ))} + + + {loaderData.container.name} + + + ), + }, + + notFound: ForumNotFound, +}); + +const TopicListRow = ({ + stats, + topic, +}: { + stats: TopicStats | undefined; + topic: TopicRow; +}) => { + const t = useTranslations("@vitnode/forum.forums"); + const commentCount = stats?.commentCount ?? 0; + + return ( +
  • + +
    +
    + + {topic.title} + +

    + + {topic.publishedAt ? ( + <> + + + + ) : null} +

    +
    + +

    + + + {stats?.lastCommentAt ? ( + + {t("last_comment")} + + ) : null} +

    +
    +
  • + ); +}; + +const ForumPage = ({ + loaderData, +}: PluginRoutePageProps) => { + const t = useTranslations("@vitnode/forum.forums"); + const { + children, + container, + forumStats, + isFirstPage, + nextCursor, + topicStats, + topics, + } = loaderData; + const statsOfTopic = indexBy(topicStats, item => item.topicId); + + return ( + + + ); +}; + +export default ForumPage; diff --git a/plugins/forum/src/pages/forum-ui.tsx b/plugins/forum/src/pages/forum-ui.tsx new file mode 100644 index 0000000000..e140255acb --- /dev/null +++ b/plugins/forum/src/pages/forum-ui.tsx @@ -0,0 +1,140 @@ +import { Avatar } from "@vitnode/core/components/avatar"; +import { DateFormat } from "@vitnode/core/components/date-format"; +import { EmojiIcon } from "@vitnode/core/components/ui/emoji-icon"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@vitnode/core/components/ui/empty"; +import { UserFormat } from "@vitnode/core/components/user-format"; +import { parseEmojiIcon } from "@vitnode/core/lib/emoji-icon"; +import { cn } from "cn"; +import { MessagesSquareIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import type { ForumPerson } from "./forum-data"; + +export const ForumShell = ({ + actions, + children, + description, + title, +}: { + actions?: React.ReactNode; + children: React.ReactNode; + description?: null | string; + title: React.ReactNode; +}) => ( +
    +
    +
    +

    + {title} +

    + {description ? ( +

    + {description} +

    + ) : null} +
    + {actions ? ( +
    {actions}
    + ) : null} +
    + + {children} +
    +); + +export const RefusedState = ({ + action, + description, + icon, + title, +}: { + action?: React.ReactNode; + description: string; + icon: React.ReactNode; + title: string; +}) => ( +
    + + + {icon} + }>{title} + {description} + + {action ? {action} : null} + +
    +); + +export const ForumIcon = ({ + className, + icon, +}: { + className?: string; + icon: null | string | undefined; +}) => { + const parsed = parseEmojiIcon(icon); + + return ( + + ); +}; + +const AVATAR_SIZE_CLASS = { 24: "size-6", 32: "size-8", 40: "size-10" }; + +export const PersonAvatar = ({ + person, + size = 32, +}: { + person: ForumPerson; + size?: keyof typeof AVATAR_SIZE_CLASS; +}) => + person ? ( + + ) : ( +