diff --git a/drizzle.config.ts b/drizzle.config.ts index ed8d9bd..6c6b8c7 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ './src/db/schema/sybil-cluster-members.ts', './src/db/schema/behavioral-flags.ts', './src/db/schema/pds-trust-factors.ts', + './src/db/schema/pages.ts', ], out: './drizzle', dialect: 'postgresql', diff --git a/drizzle/0004_add_pages_table.sql b/drizzle/0004_add_pages_table.sql new file mode 100644 index 0000000..5516cf7 --- /dev/null +++ b/drizzle/0004_add_pages_table.sql @@ -0,0 +1,21 @@ +CREATE TABLE "pages" ( + "id" text PRIMARY KEY NOT NULL, + "slug" text NOT NULL, + "title" text NOT NULL, + "content" text NOT NULL, + "status" text DEFAULT 'draft' NOT NULL, + "meta_description" text, + "parent_id" text, + "sort_order" integer DEFAULT 0 NOT NULL, + "community_did" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "pages" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "pages" ADD CONSTRAINT "pages_parent_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."pages"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "pages_slug_community_did_idx" ON "pages" USING btree ("slug","community_did");--> statement-breakpoint +CREATE INDEX "pages_community_did_idx" ON "pages" USING btree ("community_did");--> statement-breakpoint +CREATE INDEX "pages_parent_id_idx" ON "pages" USING btree ("parent_id");--> statement-breakpoint +CREATE INDEX "pages_status_community_did_idx" ON "pages" USING btree ("status","community_did");--> statement-breakpoint +CREATE POLICY "tenant_isolation" ON "pages" AS PERMISSIVE FOR ALL TO "barazo_app" USING (community_did = current_setting('app.current_community_did', true)) WITH CHECK (community_did = current_setting('app.current_community_did', true)); \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..04b7a6a --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,4023 @@ +{ + "id": "fb61c80e-ea91-48e9-8d9c-7beaf93d08f1", + "prevId": "5f8531c6-1108-4540-8776-0cb9de6ab736", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.users": { + "name": "users", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_banned": { + "name": "is_banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reputation_score": { + "name": "reputation_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "declared_age": { + "name": "declared_age", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "maturity_pref": { + "name": "maturity_pref", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'safe'" + }, + "account_created_at": { + "name": "account_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "followers_count": { + "name": "followers_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "follows_count": { + "name": "follows_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "atproto_posts_count": { + "name": "atproto_posts_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "has_bluesky_profile": { + "name": "has_bluesky_profile", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "atproto_labels": { + "name": "atproto_labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "users_role_elevated_idx": { + "name": "users_role_elevated_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "role IN ('moderator', 'admin')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_handle_idx": { + "name": "users_handle_idx", + "columns": [ + { + "expression": "handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_account_created_at_idx": { + "name": "users_account_created_at_idx", + "columns": [ + { + "expression": "account_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.firehose_cursor": { + "name": "firehose_cursor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "cursor": { + "name": "cursor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.topics": { + "name": "topics", + "schema": "", + "columns": { + "uri": { + "name": "uri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reply_count": { + "name": "reply_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reaction_count": { + "name": "reaction_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "vote_count": { + "name": "vote_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_mod_deleted": { + "name": "is_mod_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_author_deleted": { + "name": "is_author_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'approved'" + }, + "trust_status": { + "name": "trust_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'trusted'" + } + }, + "indexes": { + "topics_author_did_idx": { + "name": "topics_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_category_idx": { + "name": "topics_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_created_at_idx": { + "name": "topics_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_last_activity_at_idx": { + "name": "topics_last_activity_at_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_community_did_idx": { + "name": "topics_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_moderation_status_idx": { + "name": "topics_moderation_status_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_trust_status_idx": { + "name": "topics_trust_status_idx", + "columns": [ + { + "expression": "trust_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_community_category_activity_idx": { + "name": "topics_community_category_activity_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.replies": { + "name": "replies", + "schema": "", + "columns": { + "uri": { + "name": "uri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_uri": { + "name": "root_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_cid": { + "name": "root_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_uri": { + "name": "parent_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_cid": { + "name": "parent_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reaction_count": { + "name": "reaction_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "vote_count": { + "name": "vote_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_author_deleted": { + "name": "is_author_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_mod_deleted": { + "name": "is_mod_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'approved'" + }, + "trust_status": { + "name": "trust_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'trusted'" + } + }, + "indexes": { + "replies_author_did_idx": { + "name": "replies_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_root_uri_idx": { + "name": "replies_root_uri_idx", + "columns": [ + { + "expression": "root_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_parent_uri_idx": { + "name": "replies_parent_uri_idx", + "columns": [ + { + "expression": "parent_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_created_at_idx": { + "name": "replies_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_community_did_idx": { + "name": "replies_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_moderation_status_idx": { + "name": "replies_moderation_status_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_trust_status_idx": { + "name": "replies_trust_status_idx", + "columns": [ + { + "expression": "trust_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_root_uri_created_at_idx": { + "name": "replies_root_uri_created_at_idx", + "columns": [ + { + "expression": "root_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "uri": { + "name": "uri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_uri": { + "name": "subject_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_cid": { + "name": "subject_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reactions_author_did_idx": { + "name": "reactions_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_subject_uri_idx": { + "name": "reactions_subject_uri_idx", + "columns": [ + { + "expression": "subject_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_community_did_idx": { + "name": "reactions_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_subject_uri_type_idx": { + "name": "reactions_subject_uri_type_idx", + "columns": [ + { + "expression": "subject_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reactions_author_subject_type_uniq": { + "name": "reactions_author_subject_type_uniq", + "nullsNotDistinct": false, + "columns": [ + "author_did", + "subject_uri", + "type" + ] + } + }, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.votes": { + "name": "votes", + "schema": "", + "columns": { + "uri": { + "name": "uri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_uri": { + "name": "subject_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_cid": { + "name": "subject_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "votes_author_did_idx": { + "name": "votes_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "votes_subject_uri_idx": { + "name": "votes_subject_uri_idx", + "columns": [ + { + "expression": "subject_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "votes_community_did_idx": { + "name": "votes_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "votes_author_subject_uniq": { + "name": "votes_author_subject_uniq", + "nullsNotDistinct": false, + "columns": [ + "author_did", + "subject_uri" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_repos": { + "name": "tracked_repos", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tracked_at": { + "name": "tracked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_settings": { + "name": "community_settings", + "schema": "", + "columns": { + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "domains": { + "name": "domains", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "initialized": { + "name": "initialized", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "admin_did": { + "name": "admin_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "community_name": { + "name": "community_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Barazo Community'" + }, + "maturity_rating": { + "name": "maturity_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'safe'" + }, + "reaction_set": { + "name": "reaction_set", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"like\"]'::jsonb" + }, + "moderation_thresholds": { + "name": "moderation_thresholds", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"autoBlockReportCount\":5,\"warnThreshold\":3,\"firstPostQueueCount\":3,\"newAccountDays\":7,\"newAccountWriteRatePerMin\":3,\"establishedWriteRatePerMin\":10,\"linkHoldEnabled\":true,\"topicCreationDelayEnabled\":true,\"burstPostCount\":5,\"burstWindowMinutes\":10,\"trustedPostThreshold\":10}'::jsonb" + }, + "word_filter": { + "name": "word_filter", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "jurisdiction_country": { + "name": "jurisdiction_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "age_threshold": { + "name": "age_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 16 + }, + "require_login_for_mature": { + "name": "require_login_for_mature", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "community_description": { + "name": "community_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_endpoint": { + "name": "service_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_key": { + "name": "rotation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "community_logo_url": { + "name": "community_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "maturity_rating": { + "name": "maturity_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'safe'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "categories_slug_community_did_idx": { + "name": "categories_slug_community_did_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categories_parent_id_idx": { + "name": "categories_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categories_community_did_idx": { + "name": "categories_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categories_maturity_rating_idx": { + "name": "categories_maturity_rating_idx", + "columns": [ + { + "expression": "maturity_rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "categories_parent_id_fk": { + "name": "categories_parent_id_fk", + "tableFrom": "categories", + "tableTo": "categories", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.moderation_actions": { + "name": "moderation_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_uri": { + "name": "target_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_did": { + "name": "target_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moderator_did": { + "name": "moderator_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mod_actions_moderator_did_idx": { + "name": "mod_actions_moderator_did_idx", + "columns": [ + { + "expression": "moderator_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_actions_community_did_idx": { + "name": "mod_actions_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_actions_created_at_idx": { + "name": "mod_actions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_actions_target_uri_idx": { + "name": "mod_actions_target_uri_idx", + "columns": [ + { + "expression": "target_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_actions_target_did_idx": { + "name": "mod_actions_target_did_idx", + "columns": [ + { + "expression": "target_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "reporter_did": { + "name": "reporter_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_uri": { + "name": "target_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_did": { + "name": "target_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason_type": { + "name": "reason_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "resolution_type": { + "name": "resolution_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "appeal_reason": { + "name": "appeal_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "appealed_at": { + "name": "appealed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "appeal_status": { + "name": "appeal_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_reporter_did_idx": { + "name": "reports_reporter_did_idx", + "columns": [ + { + "expression": "reporter_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_target_uri_idx": { + "name": "reports_target_uri_idx", + "columns": [ + { + "expression": "target_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_target_did_idx": { + "name": "reports_target_did_idx", + "columns": [ + { + "expression": "target_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_community_did_idx": { + "name": "reports_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_created_at_idx": { + "name": "reports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_unique_reporter_target_idx": { + "name": "reports_unique_reporter_target_idx", + "columns": [ + { + "expression": "reporter_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "recipient_did": { + "name": "recipient_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_uri": { + "name": "subject_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_did": { + "name": "actor_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_recipient_did_idx": { + "name": "notifications_recipient_did_idx", + "columns": [ + { + "expression": "recipient_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_recipient_read_idx": { + "name": "notifications_recipient_read_idx", + "columns": [ + { + "expression": "recipient_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_at_idx": { + "name": "notifications_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user_community_preferences": { + "name": "user_community_preferences", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "maturity_override": { + "name": "maturity_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "muted_words": { + "name": "muted_words", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "blocked_dids": { + "name": "blocked_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "muted_dids": { + "name": "muted_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_community_prefs_did_idx": { + "name": "user_community_prefs_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_community_prefs_community_idx": { + "name": "user_community_prefs_community_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_community_preferences_did_community_did_pk": { + "name": "user_community_preferences_did_community_did_pk", + "columns": [ + "did", + "community_did" + ] + } + }, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "maturity_level": { + "name": "maturity_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sfw'" + }, + "declared_age": { + "name": "declared_age", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "muted_words": { + "name": "muted_words", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_dids": { + "name": "blocked_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "muted_dids": { + "name": "muted_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cross_post_bluesky": { + "name": "cross_post_bluesky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cross_post_frontpage": { + "name": "cross_post_frontpage", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cross_post_scopes_granted": { + "name": "cross_post_scopes_granted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cross_posts": { + "name": "cross_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "topic_uri": { + "name": "topic_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cross_post_uri": { + "name": "cross_post_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cross_post_cid": { + "name": "cross_post_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cross_posts_topic_uri_idx": { + "name": "cross_posts_topic_uri_idx", + "columns": [ + { + "expression": "topic_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cross_posts_author_did_idx": { + "name": "cross_posts_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_onboarding_fields": { + "name": "community_onboarding_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_mandatory": { + "name": "is_mandatory", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboarding_fields_community_idx": { + "name": "onboarding_fields_community_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user_onboarding_responses": { + "name": "user_onboarding_responses", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_id": { + "name": "field_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboarding_responses_did_community_idx": { + "name": "onboarding_responses_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_onboarding_responses_did_community_did_field_id_pk": { + "name": "user_onboarding_responses_did_community_did_field_id_pk", + "columns": [ + "did", + "community_did", + "field_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.moderation_queue": { + "name": "moderation_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "content_uri": { + "name": "content_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queue_reason": { + "name": "queue_reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "matched_words": { + "name": "matched_words", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mod_queue_author_did_idx": { + "name": "mod_queue_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_queue_community_did_idx": { + "name": "mod_queue_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_queue_status_idx": { + "name": "mod_queue_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_queue_created_at_idx": { + "name": "mod_queue_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_queue_content_uri_idx": { + "name": "mod_queue_content_uri_idx", + "columns": [ + { + "expression": "content_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.account_trust": { + "name": "account_trust", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_post_count": { + "name": "approved_post_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_trusted": { + "name": "is_trusted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trusted_at": { + "name": "trusted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "account_trust_did_community_idx": { + "name": "account_trust_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_trust_did_idx": { + "name": "account_trust_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.community_filters": { + "name": "community_filters", + "schema": "", + "columns": { + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "admin_did": { + "name": "admin_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_count": { + "name": "report_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reviewed_at": { + "name": "last_reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filtered_by": { + "name": "filtered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "community_filters_status_idx": { + "name": "community_filters_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "community_filters_admin_did_idx": { + "name": "community_filters_admin_did_idx", + "columns": [ + { + "expression": "admin_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "community_filters_updated_at_idx": { + "name": "community_filters_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.account_filters": { + "name": "account_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_count": { + "name": "report_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ban_count": { + "name": "ban_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reviewed_at": { + "name": "last_reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filtered_by": { + "name": "filtered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_filters_did_community_idx": { + "name": "account_filters_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_filters_did_idx": { + "name": "account_filters_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_filters_community_did_idx": { + "name": "account_filters_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_filters_status_idx": { + "name": "account_filters_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_filters_updated_at_idx": { + "name": "account_filters_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.ozone_labels": { + "name": "ozone_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "src": { + "name": "src", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "val": { + "name": "val", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "neg": { + "name": "neg", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cts": { + "name": "cts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "exp": { + "name": "exp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ozone_labels_src_uri_val_idx": { + "name": "ozone_labels_src_uri_val_idx", + "columns": [ + { + "expression": "src", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "val", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ozone_labels_uri_idx": { + "name": "ozone_labels_uri_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ozone_labels_val_idx": { + "name": "ozone_labels_val_idx", + "columns": [ + { + "expression": "val", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ozone_labels_indexed_at_idx": { + "name": "ozone_labels_indexed_at_idx", + "columns": [ + { + "expression": "indexed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_profiles": { + "name": "community_profiles", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "community_profiles_did_idx": { + "name": "community_profiles_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "community_profiles_community_idx": { + "name": "community_profiles_community_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "community_profiles_did_community_did_pk": { + "name": "community_profiles_did_community_did_pk", + "columns": [ + "did", + "community_did" + ] + } + }, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.interaction_graph": { + "name": "interaction_graph", + "schema": "", + "columns": { + "source_did": { + "name": "source_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_did": { + "name": "target_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_id": { + "name": "community_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interaction_type": { + "name": "interaction_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "first_interaction_at": { + "name": "first_interaction_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_interaction_at": { + "name": "last_interaction_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_graph_source_target_community_idx": { + "name": "interaction_graph_source_target_community_idx", + "columns": [ + { + "expression": "source_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "interaction_graph_source_did_target_did_community_id_interaction_type_pk": { + "name": "interaction_graph_source_did_target_did_community_id_interaction_type_pk", + "columns": [ + "source_did", + "target_did", + "community_id", + "interaction_type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trust_seeds": { + "name": "trust_seeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_id": { + "name": "community_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trust_seeds_did_community_idx": { + "name": "trust_seeds_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trust_scores": { + "name": "trust_scores", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_id": { + "name": "community_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trust_scores_did_community_idx": { + "name": "trust_scores_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trust_scores_did_community_id_pk": { + "name": "trust_scores_did_community_id_pk", + "columns": [ + "did", + "community_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sybil_clusters": { + "name": "sybil_clusters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "cluster_hash": { + "name": "cluster_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_edge_count": { + "name": "internal_edge_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "external_edge_count": { + "name": "external_edge_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "member_count": { + "name": "member_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'flagged'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sybil_clusters_hash_idx": { + "name": "sybil_clusters_hash_idx", + "columns": [ + { + "expression": "cluster_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sybil_cluster_members": { + "name": "sybil_cluster_members", + "schema": "", + "columns": { + "cluster_id": { + "name": "cluster_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_in_cluster": { + "name": "role_in_cluster", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sybil_cluster_members_cluster_id_sybil_clusters_id_fk": { + "name": "sybil_cluster_members_cluster_id_sybil_clusters_id_fk", + "tableFrom": "sybil_cluster_members", + "tableTo": "sybil_clusters", + "columnsFrom": [ + "cluster_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sybil_cluster_members_cluster_id_did_pk": { + "name": "sybil_cluster_members_cluster_id_did_pk", + "columns": [ + "cluster_id", + "did" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.behavioral_flags": { + "name": "behavioral_flags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "flag_type": { + "name": "flag_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "affected_dids": { + "name": "affected_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "behavioral_flags_flag_type_idx": { + "name": "behavioral_flags_flag_type_idx", + "columns": [ + { + "expression": "flag_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "behavioral_flags_status_idx": { + "name": "behavioral_flags_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "behavioral_flags_detected_at_idx": { + "name": "behavioral_flags_detected_at_idx", + "columns": [ + { + "expression": "detected_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pds_trust_factors": { + "name": "pds_trust_factors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pds_host": { + "name": "pds_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trust_factor": { + "name": "trust_factor", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pds_trust_factors_pds_host_idx": { + "name": "pds_trust_factors_pds_host_idx", + "columns": [ + { + "expression": "pds_host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pages_slug_community_did_idx": { + "name": "pages_slug_community_did_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_community_did_idx": { + "name": "pages_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_parent_id_idx": { + "name": "pages_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_status_community_did_idx": { + "name": "pages_status_community_did_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pages_parent_id_fk": { + "name": "pages_parent_id_fk", + "tableFrom": "pages", + "tableTo": "pages", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": { + "barazo_app": { + "name": "barazo_app", + "createDb": false, + "createRole": false, + "inherit": true + } + }, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ea1dafd..572a5b6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1772556676000, "tag": "0003_backfill-platform-age-field", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1772616692948, + "tag": "0004_add_pages_table", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/app.ts b/src/app.ts index 23817fe..45c1fa3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -29,6 +29,7 @@ import { setupRoutes } from './routes/setup.js' import { topicRoutes } from './routes/topics.js' import { replyRoutes } from './routes/replies.js' import { categoryRoutes } from './routes/categories.js' +import { pageRoutes } from './routes/pages.js' import { adminSettingsRoutes } from './routes/admin-settings.js' import { reactionRoutes } from './routes/reactions.js' import { voteRoutes } from './routes/votes.js' @@ -323,6 +324,7 @@ export async function buildApp(env: Env) { await app.register(topicRoutes()) await app.register(replyRoutes()) await app.register(categoryRoutes()) + await app.register(pageRoutes()) await app.register(adminSettingsRoutes()) await app.register(reactionRoutes()) await app.register(voteRoutes()) diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index bf2dca2..ab844eb 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -27,3 +27,4 @@ export { sybilClusters } from './sybil-clusters.js' export { sybilClusterMembers } from './sybil-cluster-members.js' export { behavioralFlags } from './behavioral-flags.js' export { pdsTrustFactors } from './pds-trust-factors.js' +export { pages } from './pages.js' diff --git a/src/db/schema/pages.ts b/src/db/schema/pages.ts new file mode 100644 index 0000000..e6a0a7f --- /dev/null +++ b/src/db/schema/pages.ts @@ -0,0 +1,51 @@ +import { + pgTable, + pgPolicy, + text, + integer, + timestamp, + index, + uniqueIndex, + foreignKey, +} from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' + +export const pages = pgTable( + 'pages', + { + id: text('id').primaryKey(), + slug: text('slug').notNull(), + title: text('title').notNull(), + content: text('content').notNull(), + status: text('status', { + enum: ['draft', 'published'], + }) + .notNull() + .default('draft'), + metaDescription: text('meta_description'), + parentId: text('parent_id'), + sortOrder: integer('sort_order').notNull().default(0), + communityDid: text('community_did').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('pages_slug_community_did_idx').on(table.slug, table.communityDid), + index('pages_community_did_idx').on(table.communityDid), + index('pages_parent_id_idx').on(table.parentId), + index('pages_status_community_did_idx').on(table.status, table.communityDid), + foreignKey({ + columns: [table.parentId], + foreignColumns: [table.id], + name: 'pages_parent_id_fk', + }).onDelete('set null'), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), + ] +).enableRLS() diff --git a/src/routes/pages.ts b/src/routes/pages.ts new file mode 100644 index 0000000..9c35248 --- /dev/null +++ b/src/routes/pages.ts @@ -0,0 +1,638 @@ +import { randomUUID } from 'node:crypto' +import { requireCommunityDid } from '../middleware/community-resolver.js' +import { eq, and } from 'drizzle-orm' +import type { FastifyPluginCallback } from 'fastify' +import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' +import { createPageSchema, updatePageSchema } from '../validation/pages.js' +import { pages } from '../db/schema/pages.js' + +/** + * Serialize a page row from the DB into a JSON-safe response object. + * Converts Date fields to ISO strings. + */ +function serializePage(row: typeof pages.$inferSelect) { + return { + id: row.id, + slug: row.slug, + title: row.title, + content: row.content, + status: row.status, + metaDescription: row.metaDescription ?? null, + parentId: row.parentId ?? null, + sortOrder: row.sortOrder, + communityDid: row.communityDid, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +interface PageTreeNode { + id: string + slug: string + title: string + content: string + status: string + metaDescription: string | null + parentId: string | null + sortOrder: number + communityDid: string + createdAt: string + updatedAt: string + children: PageTreeNode[] +} + +/** + * Build a tree structure from a flat list of pages. + * Returns only root-level pages (parentId === null), with children nested. + * Sorts children by sortOrder. + */ +function buildPageTree(rows: Array): PageTreeNode[] { + const serialized = rows.map((row) => ({ + ...serializePage(row), + children: [] as PageTreeNode[], + })) + + const byId = new Map() + for (const node of serialized) { + byId.set(node.id, node) + } + + const roots: PageTreeNode[] = [] + for (const node of serialized) { + if (node.parentId !== null) { + const parent = byId.get(node.parentId) + if (parent) { + parent.children.push(node) + } else { + // Orphan -- treat as root + roots.push(node) + } + } else { + roots.push(node) + } + } + + // Sort children by sortOrder + const sortChildren = (nodes: PageTreeNode[]) => { + nodes.sort((a, b) => a.sortOrder - b.sortOrder) + for (const node of nodes) { + sortChildren(node.children) + } + } + sortChildren(roots) + roots.sort((a, b) => a.sortOrder - b.sortOrder) + + return roots +} + +/** + * Detect circular reference: check if setting `pageId`'s parent to + * `newParentId` would create a cycle. Walks up the ancestor chain of + * `newParentId` to see if it reaches `pageId`. + */ +function wouldCreateCycle( + pageId: string, + newParentId: string, + allPages: Array<{ id: string; parentId: string | null }> +): boolean { + // Self-reference + if (pageId === newParentId) { + return true + } + + const byId = new Map() + for (const page of allPages) { + byId.set(page.id, page) + } + + // Walk up from newParentId + let current = newParentId + const visited = new Set() + while (current) { + if (current === pageId) { + return true + } + if (visited.has(current)) { + // Already in a cycle (should not happen but protect against it) + return true + } + visited.add(current) + const node = byId.get(current) + if (!node?.parentId) { + break + } + current = node.parentId + } + + return false +} + +/** + * Generate a random ID for a new page. + */ +function generateId(): string { + return `page-${randomUUID()}` +} + +// --------------------------------------------------------------------------- +// OpenAPI JSON Schema definitions +// --------------------------------------------------------------------------- + +const pageJsonSchema = { + type: 'object' as const, + properties: { + id: { type: 'string' as const }, + slug: { type: 'string' as const }, + title: { type: 'string' as const }, + content: { type: 'string' as const }, + status: { type: 'string' as const, enum: ['draft', 'published'] }, + metaDescription: { type: ['string', 'null'] as const }, + parentId: { type: ['string', 'null'] as const }, + sortOrder: { type: 'integer' as const }, + communityDid: { type: 'string' as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + updatedAt: { type: 'string' as const, format: 'date-time' as const }, + }, +} + +// --------------------------------------------------------------------------- +// Page routes plugin +// --------------------------------------------------------------------------- + +/** + * Page routes for the Barazo forum (Admin Pages / Mini-CMS). + * + * Public: + * - GET /api/pages -- List published pages (tree structure, content snippets) + * - GET /api/pages/:slug -- Get a single published page (full content) + * + * Admin: + * - GET /api/admin/pages -- List ALL pages (tree structure, including drafts) + * - GET /api/admin/pages/:id -- Get a single page by ID (full content) + * - POST /api/admin/pages -- Create a page + * - PUT /api/admin/pages/:id -- Update a page + * - DELETE /api/admin/pages/:id -- Delete a page + */ +export function pageRoutes(): FastifyPluginCallback { + return (app, _opts, done) => { + const { db, authMiddleware, requireAdmin } = app + + // ------------------------------------------------------------------- + // GET /api/pages (public, optionalAuth) + // ------------------------------------------------------------------- + + app.get( + '/api/pages', + { + preHandler: [authMiddleware.optionalAuth], + schema: { + tags: ['Pages'], + summary: 'List published pages as a tree structure', + response: { + 200: { + type: 'object', + additionalProperties: true, + properties: { + pages: { type: 'array' }, + }, + }, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + + const rows = await db + .select() + .from(pages) + .where(and(eq(pages.status, 'published'), eq(pages.communityDid, communityDid))) + + const tree = buildPageTree(rows) + + // Replace full content with snippets (first 200 chars) + const truncateContent = (nodes: PageTreeNode[]): PageTreeNode[] => { + return nodes.map((node) => ({ + ...node, + content: node.content.length > 200 ? node.content.slice(0, 200) + '...' : node.content, + children: truncateContent(node.children), + })) + } + + return reply.status(200).send({ pages: truncateContent(tree) }) + } + ) + + // ------------------------------------------------------------------- + // GET /api/pages/:slug (public, optionalAuth) + // ------------------------------------------------------------------- + + app.get( + '/api/pages/:slug', + { + preHandler: [authMiddleware.optionalAuth], + schema: { + tags: ['Pages'], + summary: 'Get a single published page by slug', + params: { + type: 'object', + required: ['slug'], + properties: { + slug: { type: 'string' }, + }, + }, + response: { + 200: pageJsonSchema, + 404: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { slug } = request.params as { slug: string } + const communityDid = requireCommunityDid(request) + + const rows = await db + .select() + .from(pages) + .where( + and( + eq(pages.slug, slug), + eq(pages.communityDid, communityDid), + eq(pages.status, 'published') + ) + ) + + const row = rows[0] + if (!row) { + throw notFound('Page not found') + } + + return reply.status(200).send(serializePage(row)) + } + ) + + // ------------------------------------------------------------------- + // GET /api/admin/pages (admin required) + // ------------------------------------------------------------------- + + app.get( + '/api/admin/pages', + { + preHandler: [requireAdmin], + schema: { + tags: ['Pages (Admin)'], + summary: 'List all pages as a tree structure (including drafts)', + security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'object', + additionalProperties: true, + properties: { + pages: { type: 'array' }, + }, + }, + 401: errorResponseSchema, + 403: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + + const rows = await db + .select() + .from(pages) + .where(eq(pages.communityDid, communityDid)) + + const tree = buildPageTree(rows) + + return reply.status(200).send({ pages: tree }) + } + ) + + // ------------------------------------------------------------------- + // GET /api/admin/pages/:id (admin required) + // ------------------------------------------------------------------- + + app.get( + '/api/admin/pages/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Pages (Admin)'], + summary: 'Get a single page by ID (including drafts)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, + }, + response: { + 200: pageJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { id } = request.params as { id: string } + + const rows = await db.select().from(pages).where(eq(pages.id, id)) + + const row = rows[0] + if (!row) { + throw notFound('Page not found') + } + + return reply.status(200).send(serializePage(row)) + } + ) + + // ------------------------------------------------------------------- + // POST /api/admin/pages (admin required) + // ------------------------------------------------------------------- + + app.post( + '/api/admin/pages', + { + preHandler: [requireAdmin], + schema: { + tags: ['Pages (Admin)'], + summary: 'Create a new page', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + required: ['title', 'slug'], + properties: { + title: { type: 'string', minLength: 1, maxLength: 200 }, + slug: { type: 'string', minLength: 1, maxLength: 100 }, + content: { type: 'string', maxLength: 100000 }, + status: { type: 'string', enum: ['draft', 'published'] }, + metaDescription: { type: ['string', 'null'], maxLength: 320 }, + parentId: { type: ['string', 'null'] }, + sortOrder: { type: 'integer', minimum: 0 }, + }, + }, + response: { + 201: pageJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 409: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const parsed = createPageSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid page data') + } + + const { title, slug, content, status, metaDescription, parentId, sortOrder } = parsed.data + const communityDid = requireCommunityDid(request) + + // Check slug uniqueness within community + const existingSlug = await db + .select() + .from(pages) + .where(and(eq(pages.slug, slug), eq(pages.communityDid, communityDid))) + + if (existingSlug.length > 0) { + throw conflict(`Page with slug "${slug}" already exists in this community`) + } + + // Validate parentId if provided + if (parentId !== undefined && parentId !== null) { + const parentRows = await db.select().from(pages).where(eq(pages.id, parentId)) + + if (parentRows.length === 0) { + throw badRequest(`Parent page "${parentId}" does not exist`) + } + } + + const now = new Date() + const id = generateId() + + const inserted = await db + .insert(pages) + .values({ + id, + slug, + title, + content, + status, + metaDescription: metaDescription ?? null, + parentId: parentId ?? null, + sortOrder: sortOrder ?? 0, + communityDid, + createdAt: now, + updatedAt: now, + }) + .returning() + + const created = inserted[0] + if (!created) { + throw badRequest('Failed to create page') + } + + app.log.info({ pageId: id, slug, adminDid: request.user?.did }, 'Page created') + + return reply.status(201).send(serializePage(created)) + } + ) + + // ------------------------------------------------------------------- + // PUT /api/admin/pages/:id (admin required) + // ------------------------------------------------------------------- + + app.put( + '/api/admin/pages/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Pages (Admin)'], + summary: 'Update a page', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, + }, + body: { + type: 'object', + properties: { + title: { type: 'string', minLength: 1, maxLength: 200 }, + slug: { type: 'string', minLength: 1, maxLength: 100 }, + content: { type: 'string', maxLength: 100000 }, + status: { type: 'string', enum: ['draft', 'published'] }, + metaDescription: { type: ['string', 'null'], maxLength: 320 }, + parentId: { type: ['string', 'null'] }, + sortOrder: { type: 'integer', minimum: 0 }, + }, + }, + response: { + 200: pageJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { id } = request.params as { id: string } + + // Find existing page + const existingRows = await db.select().from(pages).where(eq(pages.id, id)) + + const existing = existingRows[0] + if (!existing) { + throw notFound('Page not found') + } + + const parsed = updatePageSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid update data') + } + + const updates = parsed.data + const communityDid = requireCommunityDid(request) + + // Validate slug uniqueness if slug is being changed + if (updates.slug !== undefined && updates.slug !== existing.slug) { + const existingSlug = await db + .select() + .from(pages) + .where(and(eq(pages.slug, updates.slug), eq(pages.communityDid, communityDid))) + + if (existingSlug.length > 0) { + throw conflict(`Page with slug "${updates.slug}" already exists in this community`) + } + } + + // Validate parentId if provided (null = move to root, string = set parent) + if (updates.parentId !== undefined && updates.parentId !== null) { + // Check parent exists + const parentRows = await db.select().from(pages).where(eq(pages.id, updates.parentId)) + + if (parentRows.length === 0) { + throw badRequest(`Parent page "${updates.parentId}" does not exist`) + } + + // Check for circular references + if (updates.parentId === id) { + throw badRequest('Page cannot be its own parent') + } + + // Fetch all pages to check for cycles + const allPages = await db + .select() + .from(pages) + .where(eq(pages.communityDid, communityDid)) + + if (wouldCreateCycle(id, updates.parentId, allPages)) { + throw badRequest('Setting this parent would create a circular reference') + } + } + + // Build update set + const dbUpdates: Record = { + updatedAt: new Date(), + } + if (updates.title !== undefined) dbUpdates.title = updates.title + if (updates.slug !== undefined) dbUpdates.slug = updates.slug + if (updates.content !== undefined) dbUpdates.content = updates.content + if (updates.status !== undefined) dbUpdates.status = updates.status + if (updates.metaDescription !== undefined) + dbUpdates.metaDescription = updates.metaDescription ?? null + if (updates.parentId !== undefined) dbUpdates.parentId = updates.parentId ?? null + if (updates.sortOrder !== undefined) dbUpdates.sortOrder = updates.sortOrder + + const updated = await db + .update(pages) + .set(dbUpdates) + .where(eq(pages.id, id)) + .returning() + + const updatedRow = updated[0] + if (!updatedRow) { + throw notFound('Page not found after update') + } + + app.log.info( + { pageId: id, updates: Object.keys(updates), adminDid: request.user?.did }, + 'Page updated' + ) + + return reply.status(200).send(serializePage(updatedRow)) + } + ) + + // ------------------------------------------------------------------- + // DELETE /api/admin/pages/:id (admin required) + // ------------------------------------------------------------------- + + app.delete( + '/api/admin/pages/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Pages (Admin)'], + summary: 'Delete a page', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, + }, + response: { + 204: { type: 'null' }, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { id } = request.params as { id: string } + + // Find existing page + const existingRows = await db.select().from(pages).where(eq(pages.id, id)) + + const existing = existingRows[0] + if (!existing) { + throw notFound('Page not found') + } + + // Check if page has children + const childRows = await db.select().from(pages).where(eq(pages.parentId, id)) + + if (childRows.length > 0) { + throw conflict( + `Cannot delete page: it has ${String(childRows.length)} child page(s). Move or delete them first.` + ) + } + + // Delete the page + await db.delete(pages).where(eq(pages.id, id)) + + app.log.info( + { pageId: id, slug: existing.slug, adminDid: request.user?.did }, + 'Page deleted' + ) + + return reply.status(204).send() + } + ) + + done() + } +} diff --git a/src/setup/service.ts b/src/setup/service.ts index e723a47..a138611 100644 --- a/src/setup/service.ts +++ b/src/setup/service.ts @@ -1,7 +1,9 @@ +import { randomUUID } from 'node:crypto' import { eq, sql } from 'drizzle-orm' import { communitySettings } from '../db/schema/community-settings.js' import { communityOnboardingFields } from '../db/schema/onboarding-fields.js' import { users } from '../db/schema/users.js' +import { pages } from '../db/schema/pages.js' import type { Database } from '../db/index.js' import { encrypt } from '../lib/encryption.js' import type { Logger } from '../lib/logger.js' @@ -206,6 +208,52 @@ export function createSetupService( .onConflictDoNothing() logger.info({ communityDid }, 'Platform onboarding fields seeded') + // Seed default pages (Terms of Service, Privacy Policy, Cookie Policy) + const now = new Date() + const pageDefaults = [ + { + id: `page-${randomUUID()}`, + slug: 'terms-of-service', + title: 'Terms of service', + content: TERMS_OF_SERVICE_CONTENT, + status: 'published' as const, + metaDescription: 'Terms and conditions for using this forum community.', + parentId: null, + sortOrder: 0, + communityDid, + createdAt: now, + updatedAt: now, + }, + { + id: `page-${randomUUID()}`, + slug: 'privacy-policy', + title: 'Privacy policy', + content: PRIVACY_POLICY_CONTENT, + status: 'published' as const, + metaDescription: 'How we collect, use, and protect your personal data.', + parentId: null, + sortOrder: 1, + communityDid, + createdAt: now, + updatedAt: now, + }, + { + id: `page-${randomUUID()}`, + slug: 'cookie-policy', + title: 'Cookie policy', + content: COOKIE_POLICY_CONTENT, + status: 'published' as const, + metaDescription: 'Information about the cookies used on this forum.', + parentId: null, + sortOrder: 2, + communityDid, + createdAt: now, + updatedAt: now, + }, + ] + await db.insert(pages).values(pageDefaults) + logger.info({ communityDid, pageCount: pageDefaults.length }, 'Default pages seeded') + const finalName = row.communityName logger.info({ did, communityName: finalName }, 'Community initialized') @@ -228,3 +276,192 @@ export function createSetupService( return { getStatus, initialize } } + +// --------------------------------------------------------------------------- +// Default page content (markdown) +// --------------------------------------------------------------------------- + +const TERMS_OF_SERVICE_CONTENT = `## Acceptance of terms + +By accessing or using Barazo, you agree to be bound by these Terms of Service. If you do not agree to these terms, you may not use the service. Barazo reserves the right to update these terms at any time, with notice provided through the platform. + +## Eligibility + +You must be at least 16 years old to use Barazo (in accordance with the Dutch implementation of GDPR, UAVG). By using the service, you confirm that you meet this age requirement. Access to mature content may require additional age verification as required by applicable law. + +## Account and authentication + +Barazo uses the AT Protocol for authentication. You log in using your existing AT Protocol identity (e.g., a Bluesky account). You are responsible for maintaining the security of your AT Protocol account. Barazo does not store your password. + +## Content and conduct + +You retain ownership of content you post on Barazo. By posting, you grant Barazo a license to display, index, and distribute your content as part of the forum service and via the AT Protocol. + +You agree not to post content that: + +- Violates applicable laws or regulations. +- Infringes on the intellectual property rights of others. +- Contains spam, malware, or deceptive content. +- Harasses, threatens, or promotes violence against individuals or groups. +- Contains child sexual abuse material (CSAM). + +Community administrators may enforce additional content policies specific to their community. Repeated violations may result in content removal, account restrictions, or bans. + +## Content maturity ratings + +Communities and categories may be rated for content maturity (Safe for Work, Mature, or Adult). You are responsible for accurately labeling your content. Communities may require age verification to access mature content. New accounts default to safe-mode with mature content hidden. + +## Cross-posting + +Barazo may cross-post your content to connected platforms (such as Bluesky or Frontpage) when you enable this feature. Cross-posting is optional and can be controlled in your settings. Cross-posted content is subject to the terms of the destination platform. + +## Moderation and labels + +Your account may be labeled by independent moderation services (such as Bluesky's Ozone). Labels affect posting limits and content visibility. You cannot delete labels applied by labeler services, but you can dispute inaccuracies by contacting us or the labeler service. Community administrators may also apply local moderation overrides. + +## AI-generated summaries + +Barazo may generate AI-powered summaries of discussion threads. These summaries are anonymized derivative works that do not contain personal data (no usernames or verbatim quotes). AI summaries may persist after individual content is deleted, as they are regenerated from remaining content. Community administrators can disable summary preservation. + +## AT Protocol and federation + +Barazo is built on the AT Protocol, which is a federated, open network. Content you post may be indexed by other services on the AT Protocol network. Barazo cannot control how third-party services handle your data once it is published via the protocol. + +## Termination + +Barazo may suspend or terminate your access if you violate these terms. You may stop using the service at any time. Deleting your AT Protocol account or content will trigger removal of indexed data from Barazo (see our Privacy Policy for details). + +## Limitation of liability + +Barazo is provided "as is" without warranties of any kind. We are not liable for any damages arising from your use of the service, including but not limited to loss of data, service interruptions, or actions taken by community moderators or administrators. + +## Governing law + +These terms are governed by the laws of the Netherlands. Any disputes arising from these terms will be subject to the exclusive jurisdiction of the courts of the Netherlands. + +*These terms were last updated on February 2026.*` + +const PRIVACY_POLICY_CONTENT = `## Overview + +Barazo is committed to protecting your privacy. This policy explains what personal data we collect, why we collect it, how long we keep it, and what rights you have. Barazo is operated from the Netherlands and complies with the General Data Protection Regulation (GDPR). + +## What we collect + +When you use Barazo, we process the following data: + +- **AT Protocol identifiers** -- your DID (decentralized identifier) and handle, used to identify your account. +- **Profile information** -- display name and profile data retrieved from your AT Protocol PDS. +- **Content** -- posts, replies, and reactions you create on the forum, indexed from the AT Protocol firehose. +- **IP addresses** -- collected for API rate limiting and security purposes. +- **Authentication cookie** -- a single HTTP-only, Secure, SameSite=Strict refresh token cookie used to maintain your session. Access tokens are held in memory only and never stored in cookies or browser storage. +- **Moderation records** -- actions taken by moderators on your content or account. +- **Age declaration** -- stored in the forum database only (deliberately kept off your PDS to avoid broadcasting age data on a public network). +- **Per-community preferences** -- notification settings and content maturity overrides, stored locally in the forum database (not on your PDS) to protect your browsing patterns. + +## What we do not collect + +- We do not collect or store your password (authentication is handled via AT Protocol OAuth). +- We do not collect email addresses unless provided by a community admin for billing. +- We do not collect payment card details (processed by our payment provider). +- We do not use tracking cookies or analytics that profile your behavior. +- We do not use device fingerprinting. +- We do not load third-party trackers, pixels, or analytics scripts. + +## Legal basis + +We process your data under the following legal bases (GDPR Art. 6): + +- **Contract performance** -- processing necessary to provide the forum service you signed up for. +- **Legitimate interest** -- indexing public AT Protocol content, spam prevention, platform security, content moderation, and AI-generated discussion summaries. + +## Data storage and transfers + +Our servers are hosted in the European Union (Hetzner, Germany). We use the following sub-processors: + +- Hetzner (EU) -- hosting infrastructure. +- Bunny.net (EU, Slovenia) -- content delivery network. +- Stripe (EU-US Data Privacy Framework certified) -- payment processing. + +A full sub-processor list is maintained at **barazo.forum/legal/sub-processors**. + +## Data retention and deletion + +Your indexed data is retained while the source exists on your AT Protocol PDS. When you delete content or your account via the AT Protocol, we process the deletion event immediately: + +- Your post is removed from public view and replaced with a "deleted by author" notice. +- Your personal data (DID, handle, AT Protocol URI) is stripped from the database record. +- The anonymized content (with no link to your identity) may be retained to preserve community knowledge and enable AI-generated discussion summaries. This anonymized data falls outside GDPR scope (Recital 26) because it can no longer identify you. + +You may request full content deletion (including anonymized content) by contacting us directly, independent of AT Protocol signals. We respond to deletion requests within one month (GDPR Art. 12(3)). + +Barazo cannot guarantee deletion from external systems such as AT Protocol relays, other AppViews, search engine caches, or web archives. Our reasonable steps include: propagating AT Protocol delete events, submitting Google Search Console removal requests for deleted content URLs, and documenting which systems confirmed deletion. + +## AI features + +Barazo offers optional AI features including thread summaries, semantic search, and content moderation assistance. Here is how they work: + +- **No training on your content.** We do not use member posts to train AI models, and we do not provide member content to others for training. +- **Local-first processing.** The default AI configuration uses local inference (Ollama) -- your content never leaves the server. Your forum administrator may choose a different AI provider; in that case, content is sent to that provider for processing. +- **Anonymized summaries.** AI-generated thread summaries are designed to exclude usernames, handles, and verbatim quotes. Summaries capture the discussion's substance, not who said what. Summaries may persist after individual content deletion because they contain no personal data. + +## Content labels + +We subscribe to content labeling services (such as Bluesky's Ozone) for spam detection and content moderation. Labels applied to your account may affect posting limits and content visibility. Labels are stored by the labeler service, not on your PDS. You can dispute labels by contacting us. + +## Your rights + +Under the GDPR, you have the right to: + +- Access the personal data we hold about you. +- Rectify inaccurate data. +- Request erasure of your data (right to be forgotten). +- Object to processing based on legitimate interest. +- Data portability (built into the AT Protocol). +- Lodge a complaint with the Dutch Data Protection Authority (Autoriteit Persoonsgegevens). + +To exercise these rights, contact us through our [GitHub issue tracker](https://github.com/barazo-forum/barazo-workspace/issues) or via the contact details provided by your community administrator. + +## Data breach notification + +In the event of a data breach, we will notify the Dutch Data Protection Authority within 72 hours (GDPR Art. 33). For high-risk breaches, we will notify affected users without undue delay via AT Protocol notifications and public announcements. + +*This policy was last updated on February 2026.*` + +const COOKIE_POLICY_CONTENT = `## Overview + +Barazo uses a minimal number of cookies. We do not use tracking cookies, advertising cookies, or third-party analytics cookies. This page explains the cookies we do use and why. + +## Cookies we use + +Barazo uses a single essential cookie: + +| Cookie | Purpose | Duration | Type | +|--------|---------|----------|------| +| Refresh token | Keeps you logged in across page reloads by enabling silent access token renewal. | Session | Essential | + +## Technical details + +The refresh token cookie has the following security properties: + +- **HTTP-only** -- the cookie is not accessible to JavaScript, preventing cross-site scripting (XSS) attacks. +- **Secure** -- the cookie is only sent over HTTPS connections. +- **SameSite=Strict** -- the cookie is not sent with cross-site requests, preventing cross-site request forgery (CSRF) attacks. + +Access tokens (used to authenticate API requests) are held in memory only and are never stored in cookies, localStorage, or sessionStorage. + +## What we do not use + +- No tracking or advertising cookies. +- No third-party analytics (Google Analytics, etc.). +- No social media tracking pixels. +- No fingerprinting or behavioral profiling. + +## Cookie consent + +Because we only use a single essential cookie required for the service to function, a cookie consent banner is not required under the ePrivacy Directive (EU Directive 2002/58/EC, Art. 5(3)). Essential cookies that are strictly necessary for the service requested by the user are exempt from the consent requirement. + +## Theme preference + +Your light/dark mode preference is stored in localStorage (not a cookie). This is a client-side preference that is never sent to our servers. + +*This policy was last updated on February 2026.*` diff --git a/src/validation/pages.ts b/src/validation/pages.ts new file mode 100644 index 0000000..019840d --- /dev/null +++ b/src/validation/pages.ts @@ -0,0 +1,146 @@ +import { z } from 'zod/v4' + +// --------------------------------------------------------------------------- +// Shared enums +// --------------------------------------------------------------------------- + +/** Valid page status values. */ +export const pageStatusSchema = z.enum(['draft', 'published']) + +export type PageStatus = z.infer + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Slugs reserved for UI routes that must not collide with page slugs. */ +const RESERVED_SLUGS = ['new', 'edit', 'drafts'] as const + +// --------------------------------------------------------------------------- +// Request schemas +// --------------------------------------------------------------------------- + +/** Slug pattern: lowercase alphanumeric segments separated by single hyphens. */ +const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + +/** Slug field with format validation and reserved-slug rejection. */ +const slugField = z + .string() + .min(1, 'Slug is required') + .max(100, 'Slug must be at most 100 characters') + .regex( + slugPattern, + "Slug must be lowercase alphanumeric with single hyphens (e.g. 'terms-of-service')" + ) + .refine( + (val) => !RESERVED_SLUGS.includes(val as (typeof RESERVED_SLUGS)[number]), + 'This slug is reserved and cannot be used' + ) + +/** Schema for creating a new page. */ +export const createPageSchema = z.object({ + title: z + .string() + .trim() + .min(1, 'Title is required') + .max(200, 'Title must be at most 200 characters'), + slug: slugField, + content: z.string().max(100_000, 'Content must be at most 100000 characters').default(''), + status: pageStatusSchema.default('draft'), + metaDescription: z + .string() + .max(320, 'Meta description must be at most 320 characters') + .nullable() + .optional(), + parentId: z.string().nullable().optional(), + sortOrder: z + .number() + .int('Sort order must be an integer') + .min(0, 'Sort order must be non-negative') + .optional(), +}) + +export type CreatePageInput = z.infer + +/** Schema for updating an existing page (all fields optional). */ +export const updatePageSchema = z.object({ + title: z + .string() + .trim() + .min(1, 'Title must not be empty') + .max(200, 'Title must be at most 200 characters') + .optional(), + slug: slugField.optional(), + content: z + .string() + .max(100_000, 'Content must be at most 100000 characters') + .optional(), + status: pageStatusSchema.optional(), + metaDescription: z + .string() + .max(320, 'Meta description must be at most 320 characters') + .nullable() + .optional(), + parentId: z.string().nullable().optional(), + sortOrder: z + .number() + .int('Sort order must be an integer') + .min(0, 'Sort order must be non-negative') + .optional(), +}) + +export type UpdatePageInput = z.infer + +// --------------------------------------------------------------------------- +// Response schemas (for OpenAPI documentation) +// --------------------------------------------------------------------------- + +/** Schema describing a single page in API responses. */ +export const pageResponseSchema = z.object({ + id: z.string(), + slug: z.string(), + title: z.string(), + content: z.string(), + status: pageStatusSchema, + metaDescription: z.string().nullable(), + parentId: z.string().nullable(), + sortOrder: z.number(), + communityDid: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type PageResponse = z.infer + +/** Schema describing a page with its children (tree structure). */ +export const pageTreeResponseSchema: z.ZodType = z.lazy(() => + z.object({ + id: z.string(), + slug: z.string(), + title: z.string(), + content: z.string(), + status: pageStatusSchema, + metaDescription: z.string().nullable(), + parentId: z.string().nullable(), + sortOrder: z.number(), + communityDid: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + children: z.array(pageTreeResponseSchema), + }) +) + +export interface PageTreeResponse { + id: string + slug: string + title: string + content: string + status: 'draft' | 'published' + metaDescription: string | null + parentId: string | null + sortOrder: number + communityDid: string + createdAt: string + updatedAt: string + children: PageTreeResponse[] +} diff --git a/tests/unit/db/schema/pages.test.ts b/tests/unit/db/schema/pages.test.ts new file mode 100644 index 0000000..55a39e3 --- /dev/null +++ b/tests/unit/db/schema/pages.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' +import { getTableName, getTableColumns } from 'drizzle-orm' +import { pages } from '../../../../src/db/schema/pages.js' + +describe('pages schema', () => { + const columns = getTableColumns(pages) + + it('has the correct table name', () => { + expect(getTableName(pages)).toBe('pages') + }) + + it('uses id as primary key', () => { + expect(columns.id.primary).toBe(true) + }) + + it('has all required columns', () => { + const columnNames = Object.keys(columns) + + const expected = [ + 'id', + 'slug', + 'title', + 'content', + 'status', + 'metaDescription', + 'parentId', + 'sortOrder', + 'communityDid', + 'createdAt', + 'updatedAt', + ] + + for (const col of expected) { + expect(columnNames).toContain(col) + } + }) + + it('has non-nullable required columns', () => { + expect(columns.id.notNull).toBe(true) + expect(columns.slug.notNull).toBe(true) + expect(columns.title.notNull).toBe(true) + expect(columns.content.notNull).toBe(true) + expect(columns.status.notNull).toBe(true) + expect(columns.sortOrder.notNull).toBe(true) + expect(columns.communityDid.notNull).toBe(true) + expect(columns.createdAt.notNull).toBe(true) + expect(columns.updatedAt.notNull).toBe(true) + }) + + it('has nullable optional columns', () => { + expect(columns.metaDescription.notNull).toBe(false) + expect(columns.parentId.notNull).toBe(false) + }) + + it('has default value for sortOrder', () => { + expect(columns.sortOrder.hasDefault).toBe(true) + }) + + it('has default value for status', () => { + expect(columns.status.hasDefault).toBe(true) + }) + + it('has default values for timestamps', () => { + expect(columns.createdAt.hasDefault).toBe(true) + expect(columns.updatedAt.hasDefault).toBe(true) + }) +}) diff --git a/tests/unit/routes/pages.test.ts b/tests/unit/routes/pages.test.ts new file mode 100644 index 0000000..682eed9 --- /dev/null +++ b/tests/unit/routes/pages.test.ts @@ -0,0 +1,761 @@ +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' + +/* eslint-disable @typescript-eslint/no-misused-promises -- Mock implementations return thenable objects by design */ + +// Import routes (no PDS mocking needed -- pages are local-only) +import { pageRoutes } from '../../../src/routes/pages.js' + +// --------------------------------------------------------------------------- +// Mock env (minimal subset for page routes) +// --------------------------------------------------------------------------- + +const mockEnv = { + COMMUNITY_DID: 'did:plc:community123', + RATE_LIMIT_WRITE: 10, + RATE_LIMIT_READ_ANON: 100, + RATE_LIMIT_READ_AUTH: 300, +} as Env + +// --------------------------------------------------------------------------- +// Test constants +// --------------------------------------------------------------------------- + +const TEST_DID = 'did:plc:testuser123' +const TEST_HANDLE = 'jay.bsky.team' +const TEST_SID = 'a'.repeat(64) +const ADMIN_DID = 'did:plc:admin999' +const TEST_NOW = '2026-02-13T12:00:00.000Z' +const COMMUNITY_DID = 'did:plc:test' + +const PAGE_ID_1 = 'page-001' +const PAGE_ID_2 = 'page-002' + +// --------------------------------------------------------------------------- +// Mock user builders +// --------------------------------------------------------------------------- + +function testUser(overrides?: Partial): RequestUser { + return { + did: TEST_DID, + handle: TEST_HANDLE, + sid: TEST_SID, + ...overrides, + } +} + +function adminUser(): RequestUser { + return testUser({ did: ADMIN_DID, handle: 'admin.bsky.social' }) +} + +// --------------------------------------------------------------------------- +// Chainable mock DB (shared helper) +// --------------------------------------------------------------------------- + +const mockDb = createMockDb() + +let insertChain: DbChain +let selectChain: DbChain +let updateChain: DbChain +let deleteChain: DbChain + +function resetAllDbMocks(): void { + insertChain = createChainableProxy() + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + deleteChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(deleteChain) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction + mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { + await fn(mockDb) + }) +} + +// --------------------------------------------------------------------------- +// Auth middleware mocks +// --------------------------------------------------------------------------- + +function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { + return { + requireAuth: async (request, reply) => { + if (!user) { + await reply.status(401).send({ error: 'Authentication required' }) + return + } + request.user = user + }, + optionalAuth: (request, _reply) => { + if (user) { + request.user = user + } + return Promise.resolve() + }, + } +} + +// --------------------------------------------------------------------------- +// Mock requireAdmin factory +// --------------------------------------------------------------------------- + +function createMockRequireAdmin(user?: RequestUser) { + return async ( + request: { user?: RequestUser }, + reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise } } + ) => { + if (!user) { + await reply.status(401).send({ error: 'Authentication required' }) + return + } + request.user = user + if (user.did !== ADMIN_DID) { + await reply.status(403).send({ error: 'Admin access required' }) + return + } + } +} + +// --------------------------------------------------------------------------- +// Sample page row (as returned from DB) +// --------------------------------------------------------------------------- + +function samplePageRow(overrides?: Record) { + return { + id: PAGE_ID_1, + slug: 'terms-of-service', + title: 'Terms of Service', + content: '## Acceptance of terms\n\nBy accessing or using this forum...', + status: 'published', + metaDescription: 'Terms and conditions for using this forum.', + parentId: null, + sortOrder: 0, + communityDid: COMMUNITY_DID, + createdAt: new Date(TEST_NOW), + updatedAt: new Date(TEST_NOW), + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Helper: build app with mocked deps +// --------------------------------------------------------------------------- + +async function buildTestApp(user?: RequestUser): Promise { + const app = Fastify({ logger: false }) + + const authMiddleware = createMockAuthMiddleware(user) + const requireAdmin = createMockRequireAdmin(user) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', authMiddleware) + app.decorate('requireAdmin', requireAdmin as never) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = COMMUNITY_DID + done() + }) + + await app.register(pageRoutes()) + await app.ready() + + return app +} + +// =========================================================================== +// Test suite +// =========================================================================== + +describe('page routes', () => { + // ========================================================================= + // PUBLIC: GET /api/pages (list published pages as tree) + // ========================================================================= + + describe('GET /api/pages', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp() + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns published pages as a tree with content snippets', async () => { + const longContent = 'A'.repeat(300) + selectChain.where.mockImplementation(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => + { resolve([ + samplePageRow({ content: longContent }), + ]); }, + })) + + const response = await app.inject({ + method: 'GET', + url: '/api/pages', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ pages: Array<{ content: string; children: unknown[] }> }>() + expect(body.pages).toHaveLength(1) + // Content should be a snippet (first 200 chars + '...' ellipsis) + expect(body.pages[0].content.length).toBeLessThanOrEqual(203) + expect(body.pages[0].content).toMatch(/\.\.\.$/) + expect(body.pages[0].children).toEqual([]) + }) + + it('returns empty array when no published pages exist', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/pages', + }) + + expect(response.statusCode).toBe(200) + expect(response.json<{ pages: unknown[] }>().pages).toEqual([]) + }) + }) + + // ========================================================================= + // PUBLIC: GET /api/pages/:slug (single published page) + // ========================================================================= + + describe('GET /api/pages/:slug', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp() + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns a published page with full content', async () => { + selectChain.where.mockImplementation(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => + { resolve([samplePageRow()]); }, + })) + + const response = await app.inject({ + method: 'GET', + url: '/api/pages/terms-of-service', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ slug: string; content: string }>() + expect(body.slug).toBe('terms-of-service') + expect(body.content).toBe('## Acceptance of terms\n\nBy accessing or using this forum...') + }) + + it('returns 404 for a draft page (filtered out by query)', async () => { + // With status filter in the WHERE clause, a draft page is not returned by the DB + selectChain.where.mockImplementation(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([]); }, + })) + + const response = await app.inject({ + method: 'GET', + url: '/api/pages/terms-of-service', + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns 404 for non-existent page', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/pages/non-existent', + }) + + expect(response.statusCode).toBe(404) + }) + }) + + // ========================================================================= + // ADMIN: GET /api/admin/pages (list ALL pages as tree) + // ========================================================================= + + describe('GET /api/admin/pages', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns all pages including drafts', async () => { + selectChain.where.mockImplementation(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => + { resolve([ + samplePageRow(), + samplePageRow({ id: PAGE_ID_2, slug: 'draft-page', status: 'draft', sortOrder: 1 }), + ]); }, + })) + + const response = await app.inject({ + method: 'GET', + url: '/api/admin/pages', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ pages: Array<{ id: string; status: string }> }>() + expect(body.pages).toHaveLength(2) + }) + + it('returns 401 without auth', async () => { + const noAuthApp = await buildTestApp() + const response = await noAuthApp.inject({ + method: 'GET', + url: '/api/admin/pages', + }) + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) + + it('returns 403 for non-admin user', async () => { + const regularApp = await buildTestApp(testUser()) + const response = await regularApp.inject({ + method: 'GET', + url: '/api/admin/pages', + }) + expect(response.statusCode).toBe(403) + await regularApp.close() + }) + }) + + // ========================================================================= + // ADMIN: GET /api/admin/pages/:id (single page by ID) + // ========================================================================= + + describe('GET /api/admin/pages/:id', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns a page by ID with full content', async () => { + selectChain.where.mockImplementation(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => + { resolve([samplePageRow()]); }, + })) + + const response = await app.inject({ + method: 'GET', + url: `/api/admin/pages/${PAGE_ID_1}`, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ id: string; content: string }>() + expect(body.id).toBe(PAGE_ID_1) + }) + + it('returns 404 for non-existent page', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/admin/pages/page-nonexistent', + }) + + expect(response.statusCode).toBe(404) + }) + }) + + // ========================================================================= + // ADMIN: POST /api/admin/pages (create page) + // ========================================================================= + + describe('POST /api/admin/pages', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('creates a page with valid input', async () => { + const created = samplePageRow() + + // Slug uniqueness check returns empty (no conflict) + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([]); }, + })) + + // Insert returning + insertChain.returning.mockImplementationOnce(() => ({ + ...insertChain, + then: (resolve: (v: unknown) => void) => { resolve([created]); }, + })) + + const response = await app.inject({ + method: 'POST', + url: '/api/admin/pages', + payload: { + title: 'Terms of Service', + slug: 'terms-of-service', + content: '## Acceptance of terms\n\nBy accessing or using this forum...', + status: 'published', + metaDescription: 'Terms and conditions for using this forum.', + }, + }) + + expect(response.statusCode).toBe(201) + const body = response.json<{ slug: string }>() + expect(body.slug).toBe('terms-of-service') + }) + + it('returns 400 for invalid input (missing title)', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/admin/pages', + payload: { + slug: 'no-title', + }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 400 for reserved slug', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/admin/pages', + payload: { + title: 'New Page', + slug: 'new', + }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 409 for duplicate slug in same community', async () => { + // Slug uniqueness check returns existing page + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([samplePageRow()]); }, + })) + + const response = await app.inject({ + method: 'POST', + url: '/api/admin/pages', + payload: { + title: 'Duplicate', + slug: 'terms-of-service', + }, + }) + + expect(response.statusCode).toBe(409) + }) + + it('returns 400 for non-existent parentId', async () => { + // Slug uniqueness check returns empty + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([]); }, + })) + // Parent check returns empty + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([]); }, + })) + + const response = await app.inject({ + method: 'POST', + url: '/api/admin/pages', + payload: { + title: 'Child Page', + slug: 'child', + parentId: 'page-nonexistent', + }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 401 without auth', async () => { + const noAuthApp = await buildTestApp() + const response = await noAuthApp.inject({ + method: 'POST', + url: '/api/admin/pages', + payload: { title: 'Test', slug: 'test' }, + }) + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) + }) + + // ========================================================================= + // ADMIN: PUT /api/admin/pages/:id (update page) + // ========================================================================= + + describe('PUT /api/admin/pages/:id', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('updates a page title', async () => { + const existing = samplePageRow() + const updated = { ...existing, title: 'Updated Title', updatedAt: new Date() } + + // Find existing + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([existing]); }, + })) + + // Update returning + updateChain.returning.mockImplementationOnce(() => ({ + ...updateChain, + then: (resolve: (v: unknown) => void) => { resolve([updated]); }, + })) + + const response = await app.inject({ + method: 'PUT', + url: `/api/admin/pages/${PAGE_ID_1}`, + payload: { title: 'Updated Title' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ title: string }>() + expect(body.title).toBe('Updated Title') + }) + + it('returns 404 for non-existent page', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/admin/pages/page-nonexistent', + payload: { title: 'Updated' }, + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns 409 for duplicate slug on update', async () => { + const existing = samplePageRow() + + // Find existing + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([existing]); }, + })) + + // Slug uniqueness check returns existing page with different ID + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => + { resolve([samplePageRow({ id: PAGE_ID_2, slug: 'other-slug' })]); }, + })) + + const response = await app.inject({ + method: 'PUT', + url: `/api/admin/pages/${PAGE_ID_1}`, + payload: { slug: 'other-slug' }, + }) + + expect(response.statusCode).toBe(409) + }) + + it('detects circular references when updating parentId', async () => { + const parent = samplePageRow({ id: PAGE_ID_1, parentId: null }) + const child = samplePageRow({ id: PAGE_ID_2, parentId: PAGE_ID_1 }) + + // Find existing (the parent page) + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([parent]); }, + })) + + // Parent exists check + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([child]); }, + })) + + // Fetch all pages for cycle detection + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([parent, child]); }, + })) + + const response = await app.inject({ + method: 'PUT', + url: `/api/admin/pages/${PAGE_ID_1}`, + payload: { parentId: PAGE_ID_2 }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('rejects self-reference parentId', async () => { + const existing = samplePageRow() + + // Find existing + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([existing]); }, + })) + + // Parent exists check (returns existing which is the same page) + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([existing]); }, + })) + + const response = await app.inject({ + method: 'PUT', + url: `/api/admin/pages/${PAGE_ID_1}`, + payload: { parentId: PAGE_ID_1 }, + }) + + expect(response.statusCode).toBe(400) + }) + }) + + // ========================================================================= + // ADMIN: DELETE /api/admin/pages/:id (delete page) + // ========================================================================= + + describe('DELETE /api/admin/pages/:id', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('deletes a page with no children', async () => { + // Find existing + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([samplePageRow()]); }, + })) + // Check children returns empty + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([]); }, + })) + + const response = await app.inject({ + method: 'DELETE', + url: `/api/admin/pages/${PAGE_ID_1}`, + }) + + expect(response.statusCode).toBe(204) + }) + + it('returns 404 for non-existent page', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/admin/pages/page-nonexistent', + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns 409 when page has children', async () => { + // Find existing + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => { resolve([samplePageRow()]); }, + })) + // Check children returns a child + selectChain.where.mockImplementationOnce(() => ({ + ...selectChain, + then: (resolve: (v: unknown) => void) => + { resolve([samplePageRow({ id: PAGE_ID_2, parentId: PAGE_ID_1 })]); }, + })) + + const response = await app.inject({ + method: 'DELETE', + url: `/api/admin/pages/${PAGE_ID_1}`, + }) + + expect(response.statusCode).toBe(409) + }) + + it('returns 401 without auth', async () => { + const noAuthApp = await buildTestApp() + const response = await noAuthApp.inject({ + method: 'DELETE', + url: `/api/admin/pages/${PAGE_ID_1}`, + }) + expect(response.statusCode).toBe(401) + await noAuthApp.close() + }) + }) +}) diff --git a/tests/unit/setup/service.test.ts b/tests/unit/setup/service.test.ts index 58d5da7..b90ee67 100644 --- a/tests/unit/setup/service.test.ts +++ b/tests/unit/setup/service.test.ts @@ -307,8 +307,8 @@ describe('SetupService', () => { await service.initialize({ did: TEST_DID, communityDid: TEST_COMMUNITY_DID }) - // The insert should be called twice: once for community settings, once for onboarding field - expect(mocks.insertFn).toHaveBeenCalledTimes(2) + // The insert should be called three times: community settings, onboarding field, pages + expect(mocks.insertFn).toHaveBeenCalledTimes(3) // The second insert's values call should contain the platform age field const secondValuesCall = mocks.valuesFn.mock.calls[1]?.[0] as Record @@ -469,6 +469,92 @@ describe('SetupService', () => { }) }) + // ========================================================================= + // initialize (page seeding) + // ========================================================================= + + describe('initialize() page seeding', () => { + it('seeds default pages after admin promotion', async () => { + mocks.returningFn.mockResolvedValueOnce([ + { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, + ]) + + await service.initialize({ + communityDid: TEST_COMMUNITY_DID, + did: TEST_DID, + }) + + // insert is called three times: community settings, onboarding field, pages + expect(mocks.insertFn).toHaveBeenCalledTimes(3) + }) + + it('seeds exactly 3 default pages with correct slugs', async () => { + mocks.returningFn.mockResolvedValueOnce([ + { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, + ]) + + // Capture the values passed to the third insert call (pages) + let capturedPageValues: Array<{ slug: string; status: string; communityDid: string }> = [] + mocks.valuesFn.mockImplementation((vals: unknown) => { + if (Array.isArray(vals) && vals.length > 0 && 'slug' in (vals[0] as Record)) { + capturedPageValues = vals as typeof capturedPageValues + } + return { onConflictDoUpdate: mocks.onConflictDoUpdateFn, onConflictDoNothing: mocks.onConflictDoNothingFn } + }) + + await service.initialize({ + communityDid: TEST_COMMUNITY_DID, + did: TEST_DID, + }) + + expect(capturedPageValues).toHaveLength(3) + const slugs = capturedPageValues.map((v) => v.slug) + expect(slugs).toContain('terms-of-service') + expect(slugs).toContain('privacy-policy') + expect(slugs).toContain('cookie-policy') + + for (const page of capturedPageValues) { + expect(page.status).toBe('published') + expect(page.communityDid).toBe(TEST_COMMUNITY_DID) + } + }) + + it('does not seed pages when community is already initialized', async () => { + mocks.returningFn.mockResolvedValueOnce([]) + + const result = await service.initialize({ + communityDid: TEST_COMMUNITY_DID, + did: TEST_DID, + }) + + expect(result).toStrictEqual({ alreadyInitialized: true }) + // Only 1 insert call (the upsert), no pages insert + expect(mocks.insertFn).toHaveBeenCalledTimes(1) + }) + + it('logs page seeding info', async () => { + mocks.returningFn.mockResolvedValueOnce([ + { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, + ]) + + await service.initialize({ + communityDid: TEST_COMMUNITY_DID, + did: TEST_DID, + }) + + const infoFn = mockLogger.info as ReturnType + const logCalls = infoFn.mock.calls as Array<[Record, string]> + const seedLog = logCalls.find( + ([_ctx, msg]) => typeof msg === 'string' && msg.includes('Default pages seeded') + ) + expect(seedLog).toBeDefined() + if (seedLog) { + expect(seedLog[0]).toHaveProperty('communityDid', TEST_COMMUNITY_DID) + expect(seedLog[0]).toHaveProperty('pageCount', 3) + } + }) + }) + // ========================================================================= // initialize (without PlcDidService injected) // ========================================================================= diff --git a/tests/unit/validation/pages.test.ts b/tests/unit/validation/pages.test.ts new file mode 100644 index 0000000..70b5f31 --- /dev/null +++ b/tests/unit/validation/pages.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect } from 'vitest' +import { + pageStatusSchema, + createPageSchema, + updatePageSchema, + pageResponseSchema, + pageTreeResponseSchema, +} from '../../../src/validation/pages.js' + +// --------------------------------------------------------------------------- +// pageStatusSchema +// --------------------------------------------------------------------------- + +describe('pageStatusSchema', () => { + it('accepts "draft"', () => { + expect(pageStatusSchema.safeParse('draft').success).toBe(true) + }) + + it('accepts "published"', () => { + expect(pageStatusSchema.safeParse('published').success).toBe(true) + }) + + it('rejects invalid status', () => { + expect(pageStatusSchema.safeParse('archived').success).toBe(false) + expect(pageStatusSchema.safeParse('').success).toBe(false) + expect(pageStatusSchema.safeParse(42).success).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// createPageSchema +// --------------------------------------------------------------------------- + +describe('createPageSchema', () => { + const validInput = { + title: 'Terms of Service', + slug: 'terms-of-service', + content: '## Hello world', + status: 'published' as const, + } + + it('accepts valid minimal input (title + slug)', () => { + const result = createPageSchema.safeParse({ title: 'About', slug: 'about' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.content).toBe('') + expect(result.data.status).toBe('draft') + } + }) + + it('accepts valid full input', () => { + const result = createPageSchema.safeParse({ + ...validInput, + metaDescription: 'Our terms of service.', + parentId: 'page-abc', + sortOrder: 5, + }) + expect(result.success).toBe(true) + }) + + // Title validation + it('rejects empty title', () => { + expect(createPageSchema.safeParse({ ...validInput, title: '' }).success).toBe(false) + }) + + it('rejects title over 200 chars', () => { + expect(createPageSchema.safeParse({ ...validInput, title: 'x'.repeat(201) }).success).toBe( + false + ) + }) + + it('accepts title at 200 chars', () => { + expect(createPageSchema.safeParse({ ...validInput, title: 'x'.repeat(200) }).success).toBe( + true + ) + }) + + // Slug validation + it('rejects empty slug', () => { + expect(createPageSchema.safeParse({ ...validInput, slug: '' }).success).toBe(false) + }) + + it('rejects slug over 100 chars', () => { + expect(createPageSchema.safeParse({ ...validInput, slug: 'a'.repeat(101) }).success).toBe( + false + ) + }) + + it('rejects slug with uppercase', () => { + expect(createPageSchema.safeParse({ ...validInput, slug: 'About-Us' }).success).toBe(false) + }) + + it('rejects slug with spaces', () => { + expect(createPageSchema.safeParse({ ...validInput, slug: 'about us' }).success).toBe(false) + }) + + it('rejects slug with consecutive hyphens', () => { + expect(createPageSchema.safeParse({ ...validInput, slug: 'about--us' }).success).toBe(false) + }) + + it('rejects slug starting with a hyphen', () => { + expect(createPageSchema.safeParse({ ...validInput, slug: '-about' }).success).toBe(false) + }) + + it('rejects slug ending with a hyphen', () => { + expect(createPageSchema.safeParse({ ...validInput, slug: 'about-' }).success).toBe(false) + }) + + it('accepts valid slug with hyphens', () => { + expect( + createPageSchema.safeParse({ ...validInput, slug: 'terms-of-service' }).success + ).toBe(true) + }) + + // Reserved slugs + it('rejects reserved slug "new"', () => { + expect(createPageSchema.safeParse({ ...validInput, slug: 'new' }).success).toBe(false) + }) + + it('rejects reserved slug "edit"', () => { + expect(createPageSchema.safeParse({ ...validInput, slug: 'edit' }).success).toBe(false) + }) + + it('rejects reserved slug "drafts"', () => { + expect(createPageSchema.safeParse({ ...validInput, slug: 'drafts' }).success).toBe(false) + }) + + // Content validation + it('defaults content to empty string', () => { + const result = createPageSchema.safeParse({ title: 'Hi', slug: 'hi' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.content).toBe('') + } + }) + + it('rejects content over 100_000 chars', () => { + expect( + createPageSchema.safeParse({ ...validInput, content: 'x'.repeat(100_001) }).success + ).toBe(false) + }) + + it('accepts content at 100_000 chars', () => { + expect( + createPageSchema.safeParse({ ...validInput, content: 'x'.repeat(100_000) }).success + ).toBe(true) + }) + + // Status validation + it('defaults status to draft', () => { + const result = createPageSchema.safeParse({ title: 'Hi', slug: 'hi' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.status).toBe('draft') + } + }) + + it('rejects invalid status', () => { + expect(createPageSchema.safeParse({ ...validInput, status: 'archived' }).success).toBe(false) + }) + + // metaDescription validation + it('accepts null metaDescription', () => { + expect( + createPageSchema.safeParse({ ...validInput, metaDescription: null }).success + ).toBe(true) + }) + + it('rejects metaDescription over 320 chars', () => { + expect( + createPageSchema.safeParse({ ...validInput, metaDescription: 'x'.repeat(321) }).success + ).toBe(false) + }) + + it('accepts metaDescription at 320 chars', () => { + expect( + createPageSchema.safeParse({ ...validInput, metaDescription: 'x'.repeat(320) }).success + ).toBe(true) + }) + + // parentId + it('accepts parentId as a string', () => { + expect( + createPageSchema.safeParse({ ...validInput, parentId: 'page-123' }).success + ).toBe(true) + }) + + it('accepts null parentId', () => { + expect(createPageSchema.safeParse({ ...validInput, parentId: null }).success).toBe(true) + }) + + // sortOrder + it('rejects negative sortOrder', () => { + expect(createPageSchema.safeParse({ ...validInput, sortOrder: -1 }).success).toBe(false) + }) + + it('rejects non-integer sortOrder', () => { + expect(createPageSchema.safeParse({ ...validInput, sortOrder: 1.5 }).success).toBe(false) + }) + + it('accepts sortOrder of 0', () => { + expect(createPageSchema.safeParse({ ...validInput, sortOrder: 0 }).success).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// updatePageSchema +// --------------------------------------------------------------------------- + +describe('updatePageSchema', () => { + it('accepts empty object (all fields optional)', () => { + expect(updatePageSchema.safeParse({}).success).toBe(true) + }) + + it('accepts partial updates', () => { + expect(updatePageSchema.safeParse({ title: 'New Title' }).success).toBe(true) + expect(updatePageSchema.safeParse({ status: 'published' }).success).toBe(true) + expect(updatePageSchema.safeParse({ content: 'New content' }).success).toBe(true) + }) + + it('rejects empty title', () => { + expect(updatePageSchema.safeParse({ title: '' }).success).toBe(false) + }) + + it('rejects empty slug', () => { + expect(updatePageSchema.safeParse({ slug: '' }).success).toBe(false) + }) + + it('accepts nullable parentId', () => { + expect(updatePageSchema.safeParse({ parentId: null }).success).toBe(true) + }) + + it('accepts nullable metaDescription', () => { + expect(updatePageSchema.safeParse({ metaDescription: null }).success).toBe(true) + }) + + it('rejects reserved slug "new"', () => { + expect(updatePageSchema.safeParse({ slug: 'new' }).success).toBe(false) + }) + + it('rejects reserved slug "edit"', () => { + expect(updatePageSchema.safeParse({ slug: 'edit' }).success).toBe(false) + }) + + it('rejects reserved slug "drafts"', () => { + expect(updatePageSchema.safeParse({ slug: 'drafts' }).success).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// pageResponseSchema +// --------------------------------------------------------------------------- + +describe('pageResponseSchema', () => { + it('accepts a valid page response', () => { + const result = pageResponseSchema.safeParse({ + id: 'page-001', + slug: 'about', + title: 'About Us', + content: '## About', + status: 'published', + metaDescription: null, + parentId: null, + sortOrder: 0, + communityDid: 'did:plc:community123', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }) + expect(result.success).toBe(true) + }) + + it('rejects missing required fields', () => { + expect(pageResponseSchema.safeParse({ id: 'page-001' }).success).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// pageTreeResponseSchema +// --------------------------------------------------------------------------- + +describe('pageTreeResponseSchema', () => { + it('accepts a page with empty children', () => { + const result = pageTreeResponseSchema.safeParse({ + id: 'page-001', + slug: 'about', + title: 'About Us', + content: '## About', + status: 'published', + metaDescription: null, + parentId: null, + sortOrder: 0, + communityDid: 'did:plc:community123', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + children: [], + }) + expect(result.success).toBe(true) + }) + + it('accepts nested children', () => { + const result = pageTreeResponseSchema.safeParse({ + id: 'page-001', + slug: 'legal', + title: 'Legal', + content: '', + status: 'published', + metaDescription: null, + parentId: null, + sortOrder: 0, + communityDid: 'did:plc:community123', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + children: [ + { + id: 'page-002', + slug: 'terms', + title: 'Terms', + content: '## Terms', + status: 'published', + metaDescription: null, + parentId: 'page-001', + sortOrder: 0, + communityDid: 'did:plc:community123', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + children: [], + }, + ], + }) + expect(result.success).toBe(true) + }) +})