Barazo AppView backend barazo.forum

feat(pages): add admin pages mini-CMS with public rendering (#126)

* feat(pages): add admin pages mini-CMS with public rendering

Add a complete CMS system for admin-managed static pages (privacy policy,
terms of service, about, rules, etc.) replacing the hardcoded /legal/* routes.

Backend changes:
- New `pages` table with hierarchy (parentId), status (draft/published),
markdown content, SEO meta description, and tenant isolation via RLS
- Zod validation with reserved slug rejection (new, edit, drafts)
- Full CRUD API: public endpoints (GET /api/pages, GET /api/pages/:slug)
serve only published pages; admin endpoints handle drafts and tree management
- Cycle detection prevents circular parent references
- Setup service seeds 3 default pages (Terms, Privacy, Cookie Policy)
with full markdown content during community initialization
- Migration 0004_add_pages_table with proper journal chain

Closes barazo-forum/barazo-workspace#TBD

* fix(pages): resolve ESLint errors in routes and tests

authored by

Guido X Jansen and committed by
GitHub
82294c0b 7bca547a

+6376 -3
+1
drizzle.config.ts
··· 31 31 './src/db/schema/sybil-cluster-members.ts', 32 32 './src/db/schema/behavioral-flags.ts', 33 33 './src/db/schema/pds-trust-factors.ts', 34 + './src/db/schema/pages.ts', 34 35 ], 35 36 out: './drizzle', 36 37 dialect: 'postgresql',
+21
drizzle/0004_add_pages_table.sql
··· 1 + CREATE TABLE "pages" ( 2 + "id" text PRIMARY KEY NOT NULL, 3 + "slug" text NOT NULL, 4 + "title" text NOT NULL, 5 + "content" text NOT NULL, 6 + "status" text DEFAULT 'draft' NOT NULL, 7 + "meta_description" text, 8 + "parent_id" text, 9 + "sort_order" integer DEFAULT 0 NOT NULL, 10 + "community_did" text NOT NULL, 11 + "created_at" timestamp with time zone DEFAULT now() NOT NULL, 12 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL 13 + ); 14 + --> statement-breakpoint 15 + ALTER TABLE "pages" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint 16 + 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 17 + CREATE UNIQUE INDEX "pages_slug_community_did_idx" ON "pages" USING btree ("slug","community_did");--> statement-breakpoint 18 + CREATE INDEX "pages_community_did_idx" ON "pages" USING btree ("community_did");--> statement-breakpoint 19 + CREATE INDEX "pages_parent_id_idx" ON "pages" USING btree ("parent_id");--> statement-breakpoint 20 + CREATE INDEX "pages_status_community_did_idx" ON "pages" USING btree ("status","community_did");--> statement-breakpoint 21 + 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));
+4023
drizzle/meta/0004_snapshot.json
··· 1 + { 2 + "id": "fb61c80e-ea91-48e9-8d9c-7beaf93d08f1", 3 + "prevId": "5f8531c6-1108-4540-8776-0cb9de6ab736", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.users": { 8 + "name": "users", 9 + "schema": "", 10 + "columns": { 11 + "did": { 12 + "name": "did", 13 + "type": "text", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "handle": { 18 + "name": "handle", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "display_name": { 24 + "name": "display_name", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": false 28 + }, 29 + "avatar_url": { 30 + "name": "avatar_url", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": false 34 + }, 35 + "banner_url": { 36 + "name": "banner_url", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": false 40 + }, 41 + "bio": { 42 + "name": "bio", 43 + "type": "text", 44 + "primaryKey": false, 45 + "notNull": false 46 + }, 47 + "role": { 48 + "name": "role", 49 + "type": "text", 50 + "primaryKey": false, 51 + "notNull": true, 52 + "default": "'user'" 53 + }, 54 + "is_banned": { 55 + "name": "is_banned", 56 + "type": "boolean", 57 + "primaryKey": false, 58 + "notNull": true, 59 + "default": false 60 + }, 61 + "reputation_score": { 62 + "name": "reputation_score", 63 + "type": "integer", 64 + "primaryKey": false, 65 + "notNull": true, 66 + "default": 0 67 + }, 68 + "first_seen_at": { 69 + "name": "first_seen_at", 70 + "type": "timestamp with time zone", 71 + "primaryKey": false, 72 + "notNull": true, 73 + "default": "now()" 74 + }, 75 + "last_active_at": { 76 + "name": "last_active_at", 77 + "type": "timestamp with time zone", 78 + "primaryKey": false, 79 + "notNull": true, 80 + "default": "now()" 81 + }, 82 + "declared_age": { 83 + "name": "declared_age", 84 + "type": "integer", 85 + "primaryKey": false, 86 + "notNull": false 87 + }, 88 + "maturity_pref": { 89 + "name": "maturity_pref", 90 + "type": "text", 91 + "primaryKey": false, 92 + "notNull": true, 93 + "default": "'safe'" 94 + }, 95 + "account_created_at": { 96 + "name": "account_created_at", 97 + "type": "timestamp with time zone", 98 + "primaryKey": false, 99 + "notNull": false 100 + }, 101 + "followers_count": { 102 + "name": "followers_count", 103 + "type": "integer", 104 + "primaryKey": false, 105 + "notNull": true, 106 + "default": 0 107 + }, 108 + "follows_count": { 109 + "name": "follows_count", 110 + "type": "integer", 111 + "primaryKey": false, 112 + "notNull": true, 113 + "default": 0 114 + }, 115 + "atproto_posts_count": { 116 + "name": "atproto_posts_count", 117 + "type": "integer", 118 + "primaryKey": false, 119 + "notNull": true, 120 + "default": 0 121 + }, 122 + "has_bluesky_profile": { 123 + "name": "has_bluesky_profile", 124 + "type": "boolean", 125 + "primaryKey": false, 126 + "notNull": true, 127 + "default": false 128 + }, 129 + "atproto_labels": { 130 + "name": "atproto_labels", 131 + "type": "jsonb", 132 + "primaryKey": false, 133 + "notNull": true, 134 + "default": "'[]'::jsonb" 135 + } 136 + }, 137 + "indexes": { 138 + "users_role_elevated_idx": { 139 + "name": "users_role_elevated_idx", 140 + "columns": [ 141 + { 142 + "expression": "role", 143 + "isExpression": false, 144 + "asc": true, 145 + "nulls": "last" 146 + } 147 + ], 148 + "isUnique": false, 149 + "where": "role IN ('moderator', 'admin')", 150 + "concurrently": false, 151 + "method": "btree", 152 + "with": {} 153 + }, 154 + "users_handle_idx": { 155 + "name": "users_handle_idx", 156 + "columns": [ 157 + { 158 + "expression": "handle", 159 + "isExpression": false, 160 + "asc": true, 161 + "nulls": "last" 162 + } 163 + ], 164 + "isUnique": false, 165 + "concurrently": false, 166 + "method": "btree", 167 + "with": {} 168 + }, 169 + "users_account_created_at_idx": { 170 + "name": "users_account_created_at_idx", 171 + "columns": [ 172 + { 173 + "expression": "account_created_at", 174 + "isExpression": false, 175 + "asc": true, 176 + "nulls": "last" 177 + } 178 + ], 179 + "isUnique": false, 180 + "concurrently": false, 181 + "method": "btree", 182 + "with": {} 183 + } 184 + }, 185 + "foreignKeys": {}, 186 + "compositePrimaryKeys": {}, 187 + "uniqueConstraints": {}, 188 + "policies": {}, 189 + "checkConstraints": {}, 190 + "isRLSEnabled": false 191 + }, 192 + "public.firehose_cursor": { 193 + "name": "firehose_cursor", 194 + "schema": "", 195 + "columns": { 196 + "id": { 197 + "name": "id", 198 + "type": "text", 199 + "primaryKey": true, 200 + "notNull": true, 201 + "default": "'default'" 202 + }, 203 + "cursor": { 204 + "name": "cursor", 205 + "type": "bigint", 206 + "primaryKey": false, 207 + "notNull": false 208 + }, 209 + "updated_at": { 210 + "name": "updated_at", 211 + "type": "timestamp with time zone", 212 + "primaryKey": false, 213 + "notNull": true, 214 + "default": "now()" 215 + } 216 + }, 217 + "indexes": {}, 218 + "foreignKeys": {}, 219 + "compositePrimaryKeys": {}, 220 + "uniqueConstraints": {}, 221 + "policies": {}, 222 + "checkConstraints": {}, 223 + "isRLSEnabled": false 224 + }, 225 + "public.topics": { 226 + "name": "topics", 227 + "schema": "", 228 + "columns": { 229 + "uri": { 230 + "name": "uri", 231 + "type": "text", 232 + "primaryKey": true, 233 + "notNull": true 234 + }, 235 + "rkey": { 236 + "name": "rkey", 237 + "type": "text", 238 + "primaryKey": false, 239 + "notNull": true 240 + }, 241 + "author_did": { 242 + "name": "author_did", 243 + "type": "text", 244 + "primaryKey": false, 245 + "notNull": true 246 + }, 247 + "title": { 248 + "name": "title", 249 + "type": "text", 250 + "primaryKey": false, 251 + "notNull": true 252 + }, 253 + "content": { 254 + "name": "content", 255 + "type": "text", 256 + "primaryKey": false, 257 + "notNull": true 258 + }, 259 + "content_format": { 260 + "name": "content_format", 261 + "type": "text", 262 + "primaryKey": false, 263 + "notNull": false 264 + }, 265 + "category": { 266 + "name": "category", 267 + "type": "text", 268 + "primaryKey": false, 269 + "notNull": true 270 + }, 271 + "tags": { 272 + "name": "tags", 273 + "type": "jsonb", 274 + "primaryKey": false, 275 + "notNull": false 276 + }, 277 + "community_did": { 278 + "name": "community_did", 279 + "type": "text", 280 + "primaryKey": false, 281 + "notNull": true 282 + }, 283 + "cid": { 284 + "name": "cid", 285 + "type": "text", 286 + "primaryKey": false, 287 + "notNull": true 288 + }, 289 + "labels": { 290 + "name": "labels", 291 + "type": "jsonb", 292 + "primaryKey": false, 293 + "notNull": false 294 + }, 295 + "reply_count": { 296 + "name": "reply_count", 297 + "type": "integer", 298 + "primaryKey": false, 299 + "notNull": true, 300 + "default": 0 301 + }, 302 + "reaction_count": { 303 + "name": "reaction_count", 304 + "type": "integer", 305 + "primaryKey": false, 306 + "notNull": true, 307 + "default": 0 308 + }, 309 + "vote_count": { 310 + "name": "vote_count", 311 + "type": "integer", 312 + "primaryKey": false, 313 + "notNull": true, 314 + "default": 0 315 + }, 316 + "last_activity_at": { 317 + "name": "last_activity_at", 318 + "type": "timestamp with time zone", 319 + "primaryKey": false, 320 + "notNull": true, 321 + "default": "now()" 322 + }, 323 + "created_at": { 324 + "name": "created_at", 325 + "type": "timestamp with time zone", 326 + "primaryKey": false, 327 + "notNull": true 328 + }, 329 + "indexed_at": { 330 + "name": "indexed_at", 331 + "type": "timestamp with time zone", 332 + "primaryKey": false, 333 + "notNull": true, 334 + "default": "now()" 335 + }, 336 + "is_locked": { 337 + "name": "is_locked", 338 + "type": "boolean", 339 + "primaryKey": false, 340 + "notNull": true, 341 + "default": false 342 + }, 343 + "is_pinned": { 344 + "name": "is_pinned", 345 + "type": "boolean", 346 + "primaryKey": false, 347 + "notNull": true, 348 + "default": false 349 + }, 350 + "is_mod_deleted": { 351 + "name": "is_mod_deleted", 352 + "type": "boolean", 353 + "primaryKey": false, 354 + "notNull": true, 355 + "default": false 356 + }, 357 + "is_author_deleted": { 358 + "name": "is_author_deleted", 359 + "type": "boolean", 360 + "primaryKey": false, 361 + "notNull": true, 362 + "default": false 363 + }, 364 + "moderation_status": { 365 + "name": "moderation_status", 366 + "type": "text", 367 + "primaryKey": false, 368 + "notNull": true, 369 + "default": "'approved'" 370 + }, 371 + "trust_status": { 372 + "name": "trust_status", 373 + "type": "text", 374 + "primaryKey": false, 375 + "notNull": true, 376 + "default": "'trusted'" 377 + } 378 + }, 379 + "indexes": { 380 + "topics_author_did_idx": { 381 + "name": "topics_author_did_idx", 382 + "columns": [ 383 + { 384 + "expression": "author_did", 385 + "isExpression": false, 386 + "asc": true, 387 + "nulls": "last" 388 + } 389 + ], 390 + "isUnique": false, 391 + "concurrently": false, 392 + "method": "btree", 393 + "with": {} 394 + }, 395 + "topics_category_idx": { 396 + "name": "topics_category_idx", 397 + "columns": [ 398 + { 399 + "expression": "category", 400 + "isExpression": false, 401 + "asc": true, 402 + "nulls": "last" 403 + } 404 + ], 405 + "isUnique": false, 406 + "concurrently": false, 407 + "method": "btree", 408 + "with": {} 409 + }, 410 + "topics_created_at_idx": { 411 + "name": "topics_created_at_idx", 412 + "columns": [ 413 + { 414 + "expression": "created_at", 415 + "isExpression": false, 416 + "asc": true, 417 + "nulls": "last" 418 + } 419 + ], 420 + "isUnique": false, 421 + "concurrently": false, 422 + "method": "btree", 423 + "with": {} 424 + }, 425 + "topics_last_activity_at_idx": { 426 + "name": "topics_last_activity_at_idx", 427 + "columns": [ 428 + { 429 + "expression": "last_activity_at", 430 + "isExpression": false, 431 + "asc": true, 432 + "nulls": "last" 433 + } 434 + ], 435 + "isUnique": false, 436 + "concurrently": false, 437 + "method": "btree", 438 + "with": {} 439 + }, 440 + "topics_community_did_idx": { 441 + "name": "topics_community_did_idx", 442 + "columns": [ 443 + { 444 + "expression": "community_did", 445 + "isExpression": false, 446 + "asc": true, 447 + "nulls": "last" 448 + } 449 + ], 450 + "isUnique": false, 451 + "concurrently": false, 452 + "method": "btree", 453 + "with": {} 454 + }, 455 + "topics_moderation_status_idx": { 456 + "name": "topics_moderation_status_idx", 457 + "columns": [ 458 + { 459 + "expression": "moderation_status", 460 + "isExpression": false, 461 + "asc": true, 462 + "nulls": "last" 463 + } 464 + ], 465 + "isUnique": false, 466 + "concurrently": false, 467 + "method": "btree", 468 + "with": {} 469 + }, 470 + "topics_trust_status_idx": { 471 + "name": "topics_trust_status_idx", 472 + "columns": [ 473 + { 474 + "expression": "trust_status", 475 + "isExpression": false, 476 + "asc": true, 477 + "nulls": "last" 478 + } 479 + ], 480 + "isUnique": false, 481 + "concurrently": false, 482 + "method": "btree", 483 + "with": {} 484 + }, 485 + "topics_community_category_activity_idx": { 486 + "name": "topics_community_category_activity_idx", 487 + "columns": [ 488 + { 489 + "expression": "community_did", 490 + "isExpression": false, 491 + "asc": true, 492 + "nulls": "last" 493 + }, 494 + { 495 + "expression": "category", 496 + "isExpression": false, 497 + "asc": true, 498 + "nulls": "last" 499 + }, 500 + { 501 + "expression": "last_activity_at", 502 + "isExpression": false, 503 + "asc": true, 504 + "nulls": "last" 505 + } 506 + ], 507 + "isUnique": false, 508 + "concurrently": false, 509 + "method": "btree", 510 + "with": {} 511 + } 512 + }, 513 + "foreignKeys": {}, 514 + "compositePrimaryKeys": {}, 515 + "uniqueConstraints": {}, 516 + "policies": { 517 + "tenant_isolation": { 518 + "name": "tenant_isolation", 519 + "as": "PERMISSIVE", 520 + "for": "ALL", 521 + "to": [ 522 + "barazo_app" 523 + ], 524 + "using": "community_did = current_setting('app.current_community_did', true)", 525 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 526 + } 527 + }, 528 + "checkConstraints": {}, 529 + "isRLSEnabled": true 530 + }, 531 + "public.replies": { 532 + "name": "replies", 533 + "schema": "", 534 + "columns": { 535 + "uri": { 536 + "name": "uri", 537 + "type": "text", 538 + "primaryKey": true, 539 + "notNull": true 540 + }, 541 + "rkey": { 542 + "name": "rkey", 543 + "type": "text", 544 + "primaryKey": false, 545 + "notNull": true 546 + }, 547 + "author_did": { 548 + "name": "author_did", 549 + "type": "text", 550 + "primaryKey": false, 551 + "notNull": true 552 + }, 553 + "content": { 554 + "name": "content", 555 + "type": "text", 556 + "primaryKey": false, 557 + "notNull": true 558 + }, 559 + "content_format": { 560 + "name": "content_format", 561 + "type": "text", 562 + "primaryKey": false, 563 + "notNull": false 564 + }, 565 + "root_uri": { 566 + "name": "root_uri", 567 + "type": "text", 568 + "primaryKey": false, 569 + "notNull": true 570 + }, 571 + "root_cid": { 572 + "name": "root_cid", 573 + "type": "text", 574 + "primaryKey": false, 575 + "notNull": true 576 + }, 577 + "parent_uri": { 578 + "name": "parent_uri", 579 + "type": "text", 580 + "primaryKey": false, 581 + "notNull": true 582 + }, 583 + "parent_cid": { 584 + "name": "parent_cid", 585 + "type": "text", 586 + "primaryKey": false, 587 + "notNull": true 588 + }, 589 + "community_did": { 590 + "name": "community_did", 591 + "type": "text", 592 + "primaryKey": false, 593 + "notNull": true 594 + }, 595 + "cid": { 596 + "name": "cid", 597 + "type": "text", 598 + "primaryKey": false, 599 + "notNull": true 600 + }, 601 + "labels": { 602 + "name": "labels", 603 + "type": "jsonb", 604 + "primaryKey": false, 605 + "notNull": false 606 + }, 607 + "reaction_count": { 608 + "name": "reaction_count", 609 + "type": "integer", 610 + "primaryKey": false, 611 + "notNull": true, 612 + "default": 0 613 + }, 614 + "vote_count": { 615 + "name": "vote_count", 616 + "type": "integer", 617 + "primaryKey": false, 618 + "notNull": true, 619 + "default": 0 620 + }, 621 + "created_at": { 622 + "name": "created_at", 623 + "type": "timestamp with time zone", 624 + "primaryKey": false, 625 + "notNull": true 626 + }, 627 + "indexed_at": { 628 + "name": "indexed_at", 629 + "type": "timestamp with time zone", 630 + "primaryKey": false, 631 + "notNull": true, 632 + "default": "now()" 633 + }, 634 + "is_author_deleted": { 635 + "name": "is_author_deleted", 636 + "type": "boolean", 637 + "primaryKey": false, 638 + "notNull": true, 639 + "default": false 640 + }, 641 + "is_mod_deleted": { 642 + "name": "is_mod_deleted", 643 + "type": "boolean", 644 + "primaryKey": false, 645 + "notNull": true, 646 + "default": false 647 + }, 648 + "moderation_status": { 649 + "name": "moderation_status", 650 + "type": "text", 651 + "primaryKey": false, 652 + "notNull": true, 653 + "default": "'approved'" 654 + }, 655 + "trust_status": { 656 + "name": "trust_status", 657 + "type": "text", 658 + "primaryKey": false, 659 + "notNull": true, 660 + "default": "'trusted'" 661 + } 662 + }, 663 + "indexes": { 664 + "replies_author_did_idx": { 665 + "name": "replies_author_did_idx", 666 + "columns": [ 667 + { 668 + "expression": "author_did", 669 + "isExpression": false, 670 + "asc": true, 671 + "nulls": "last" 672 + } 673 + ], 674 + "isUnique": false, 675 + "concurrently": false, 676 + "method": "btree", 677 + "with": {} 678 + }, 679 + "replies_root_uri_idx": { 680 + "name": "replies_root_uri_idx", 681 + "columns": [ 682 + { 683 + "expression": "root_uri", 684 + "isExpression": false, 685 + "asc": true, 686 + "nulls": "last" 687 + } 688 + ], 689 + "isUnique": false, 690 + "concurrently": false, 691 + "method": "btree", 692 + "with": {} 693 + }, 694 + "replies_parent_uri_idx": { 695 + "name": "replies_parent_uri_idx", 696 + "columns": [ 697 + { 698 + "expression": "parent_uri", 699 + "isExpression": false, 700 + "asc": true, 701 + "nulls": "last" 702 + } 703 + ], 704 + "isUnique": false, 705 + "concurrently": false, 706 + "method": "btree", 707 + "with": {} 708 + }, 709 + "replies_created_at_idx": { 710 + "name": "replies_created_at_idx", 711 + "columns": [ 712 + { 713 + "expression": "created_at", 714 + "isExpression": false, 715 + "asc": true, 716 + "nulls": "last" 717 + } 718 + ], 719 + "isUnique": false, 720 + "concurrently": false, 721 + "method": "btree", 722 + "with": {} 723 + }, 724 + "replies_community_did_idx": { 725 + "name": "replies_community_did_idx", 726 + "columns": [ 727 + { 728 + "expression": "community_did", 729 + "isExpression": false, 730 + "asc": true, 731 + "nulls": "last" 732 + } 733 + ], 734 + "isUnique": false, 735 + "concurrently": false, 736 + "method": "btree", 737 + "with": {} 738 + }, 739 + "replies_moderation_status_idx": { 740 + "name": "replies_moderation_status_idx", 741 + "columns": [ 742 + { 743 + "expression": "moderation_status", 744 + "isExpression": false, 745 + "asc": true, 746 + "nulls": "last" 747 + } 748 + ], 749 + "isUnique": false, 750 + "concurrently": false, 751 + "method": "btree", 752 + "with": {} 753 + }, 754 + "replies_trust_status_idx": { 755 + "name": "replies_trust_status_idx", 756 + "columns": [ 757 + { 758 + "expression": "trust_status", 759 + "isExpression": false, 760 + "asc": true, 761 + "nulls": "last" 762 + } 763 + ], 764 + "isUnique": false, 765 + "concurrently": false, 766 + "method": "btree", 767 + "with": {} 768 + }, 769 + "replies_root_uri_created_at_idx": { 770 + "name": "replies_root_uri_created_at_idx", 771 + "columns": [ 772 + { 773 + "expression": "root_uri", 774 + "isExpression": false, 775 + "asc": true, 776 + "nulls": "last" 777 + }, 778 + { 779 + "expression": "created_at", 780 + "isExpression": false, 781 + "asc": true, 782 + "nulls": "last" 783 + } 784 + ], 785 + "isUnique": false, 786 + "concurrently": false, 787 + "method": "btree", 788 + "with": {} 789 + } 790 + }, 791 + "foreignKeys": {}, 792 + "compositePrimaryKeys": {}, 793 + "uniqueConstraints": {}, 794 + "policies": { 795 + "tenant_isolation": { 796 + "name": "tenant_isolation", 797 + "as": "PERMISSIVE", 798 + "for": "ALL", 799 + "to": [ 800 + "barazo_app" 801 + ], 802 + "using": "community_did = current_setting('app.current_community_did', true)", 803 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 804 + } 805 + }, 806 + "checkConstraints": {}, 807 + "isRLSEnabled": true 808 + }, 809 + "public.reactions": { 810 + "name": "reactions", 811 + "schema": "", 812 + "columns": { 813 + "uri": { 814 + "name": "uri", 815 + "type": "text", 816 + "primaryKey": true, 817 + "notNull": true 818 + }, 819 + "rkey": { 820 + "name": "rkey", 821 + "type": "text", 822 + "primaryKey": false, 823 + "notNull": true 824 + }, 825 + "author_did": { 826 + "name": "author_did", 827 + "type": "text", 828 + "primaryKey": false, 829 + "notNull": true 830 + }, 831 + "subject_uri": { 832 + "name": "subject_uri", 833 + "type": "text", 834 + "primaryKey": false, 835 + "notNull": true 836 + }, 837 + "subject_cid": { 838 + "name": "subject_cid", 839 + "type": "text", 840 + "primaryKey": false, 841 + "notNull": true 842 + }, 843 + "type": { 844 + "name": "type", 845 + "type": "text", 846 + "primaryKey": false, 847 + "notNull": true 848 + }, 849 + "community_did": { 850 + "name": "community_did", 851 + "type": "text", 852 + "primaryKey": false, 853 + "notNull": true 854 + }, 855 + "cid": { 856 + "name": "cid", 857 + "type": "text", 858 + "primaryKey": false, 859 + "notNull": true 860 + }, 861 + "created_at": { 862 + "name": "created_at", 863 + "type": "timestamp with time zone", 864 + "primaryKey": false, 865 + "notNull": true 866 + }, 867 + "indexed_at": { 868 + "name": "indexed_at", 869 + "type": "timestamp with time zone", 870 + "primaryKey": false, 871 + "notNull": true, 872 + "default": "now()" 873 + } 874 + }, 875 + "indexes": { 876 + "reactions_author_did_idx": { 877 + "name": "reactions_author_did_idx", 878 + "columns": [ 879 + { 880 + "expression": "author_did", 881 + "isExpression": false, 882 + "asc": true, 883 + "nulls": "last" 884 + } 885 + ], 886 + "isUnique": false, 887 + "concurrently": false, 888 + "method": "btree", 889 + "with": {} 890 + }, 891 + "reactions_subject_uri_idx": { 892 + "name": "reactions_subject_uri_idx", 893 + "columns": [ 894 + { 895 + "expression": "subject_uri", 896 + "isExpression": false, 897 + "asc": true, 898 + "nulls": "last" 899 + } 900 + ], 901 + "isUnique": false, 902 + "concurrently": false, 903 + "method": "btree", 904 + "with": {} 905 + }, 906 + "reactions_community_did_idx": { 907 + "name": "reactions_community_did_idx", 908 + "columns": [ 909 + { 910 + "expression": "community_did", 911 + "isExpression": false, 912 + "asc": true, 913 + "nulls": "last" 914 + } 915 + ], 916 + "isUnique": false, 917 + "concurrently": false, 918 + "method": "btree", 919 + "with": {} 920 + }, 921 + "reactions_subject_uri_type_idx": { 922 + "name": "reactions_subject_uri_type_idx", 923 + "columns": [ 924 + { 925 + "expression": "subject_uri", 926 + "isExpression": false, 927 + "asc": true, 928 + "nulls": "last" 929 + }, 930 + { 931 + "expression": "type", 932 + "isExpression": false, 933 + "asc": true, 934 + "nulls": "last" 935 + } 936 + ], 937 + "isUnique": false, 938 + "concurrently": false, 939 + "method": "btree", 940 + "with": {} 941 + } 942 + }, 943 + "foreignKeys": {}, 944 + "compositePrimaryKeys": {}, 945 + "uniqueConstraints": { 946 + "reactions_author_subject_type_uniq": { 947 + "name": "reactions_author_subject_type_uniq", 948 + "nullsNotDistinct": false, 949 + "columns": [ 950 + "author_did", 951 + "subject_uri", 952 + "type" 953 + ] 954 + } 955 + }, 956 + "policies": { 957 + "tenant_isolation": { 958 + "name": "tenant_isolation", 959 + "as": "PERMISSIVE", 960 + "for": "ALL", 961 + "to": [ 962 + "barazo_app" 963 + ], 964 + "using": "community_did = current_setting('app.current_community_did', true)", 965 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 966 + } 967 + }, 968 + "checkConstraints": {}, 969 + "isRLSEnabled": true 970 + }, 971 + "public.votes": { 972 + "name": "votes", 973 + "schema": "", 974 + "columns": { 975 + "uri": { 976 + "name": "uri", 977 + "type": "text", 978 + "primaryKey": true, 979 + "notNull": true 980 + }, 981 + "rkey": { 982 + "name": "rkey", 983 + "type": "text", 984 + "primaryKey": false, 985 + "notNull": true 986 + }, 987 + "author_did": { 988 + "name": "author_did", 989 + "type": "text", 990 + "primaryKey": false, 991 + "notNull": true 992 + }, 993 + "subject_uri": { 994 + "name": "subject_uri", 995 + "type": "text", 996 + "primaryKey": false, 997 + "notNull": true 998 + }, 999 + "subject_cid": { 1000 + "name": "subject_cid", 1001 + "type": "text", 1002 + "primaryKey": false, 1003 + "notNull": true 1004 + }, 1005 + "direction": { 1006 + "name": "direction", 1007 + "type": "text", 1008 + "primaryKey": false, 1009 + "notNull": true 1010 + }, 1011 + "community_did": { 1012 + "name": "community_did", 1013 + "type": "text", 1014 + "primaryKey": false, 1015 + "notNull": true 1016 + }, 1017 + "cid": { 1018 + "name": "cid", 1019 + "type": "text", 1020 + "primaryKey": false, 1021 + "notNull": true 1022 + }, 1023 + "created_at": { 1024 + "name": "created_at", 1025 + "type": "timestamp with time zone", 1026 + "primaryKey": false, 1027 + "notNull": true 1028 + }, 1029 + "indexed_at": { 1030 + "name": "indexed_at", 1031 + "type": "timestamp with time zone", 1032 + "primaryKey": false, 1033 + "notNull": true, 1034 + "default": "now()" 1035 + } 1036 + }, 1037 + "indexes": { 1038 + "votes_author_did_idx": { 1039 + "name": "votes_author_did_idx", 1040 + "columns": [ 1041 + { 1042 + "expression": "author_did", 1043 + "isExpression": false, 1044 + "asc": true, 1045 + "nulls": "last" 1046 + } 1047 + ], 1048 + "isUnique": false, 1049 + "concurrently": false, 1050 + "method": "btree", 1051 + "with": {} 1052 + }, 1053 + "votes_subject_uri_idx": { 1054 + "name": "votes_subject_uri_idx", 1055 + "columns": [ 1056 + { 1057 + "expression": "subject_uri", 1058 + "isExpression": false, 1059 + "asc": true, 1060 + "nulls": "last" 1061 + } 1062 + ], 1063 + "isUnique": false, 1064 + "concurrently": false, 1065 + "method": "btree", 1066 + "with": {} 1067 + }, 1068 + "votes_community_did_idx": { 1069 + "name": "votes_community_did_idx", 1070 + "columns": [ 1071 + { 1072 + "expression": "community_did", 1073 + "isExpression": false, 1074 + "asc": true, 1075 + "nulls": "last" 1076 + } 1077 + ], 1078 + "isUnique": false, 1079 + "concurrently": false, 1080 + "method": "btree", 1081 + "with": {} 1082 + } 1083 + }, 1084 + "foreignKeys": {}, 1085 + "compositePrimaryKeys": {}, 1086 + "uniqueConstraints": { 1087 + "votes_author_subject_uniq": { 1088 + "name": "votes_author_subject_uniq", 1089 + "nullsNotDistinct": false, 1090 + "columns": [ 1091 + "author_did", 1092 + "subject_uri" 1093 + ] 1094 + } 1095 + }, 1096 + "policies": {}, 1097 + "checkConstraints": {}, 1098 + "isRLSEnabled": false 1099 + }, 1100 + "public.tracked_repos": { 1101 + "name": "tracked_repos", 1102 + "schema": "", 1103 + "columns": { 1104 + "did": { 1105 + "name": "did", 1106 + "type": "text", 1107 + "primaryKey": true, 1108 + "notNull": true 1109 + }, 1110 + "tracked_at": { 1111 + "name": "tracked_at", 1112 + "type": "timestamp with time zone", 1113 + "primaryKey": false, 1114 + "notNull": true, 1115 + "default": "now()" 1116 + } 1117 + }, 1118 + "indexes": {}, 1119 + "foreignKeys": {}, 1120 + "compositePrimaryKeys": {}, 1121 + "uniqueConstraints": {}, 1122 + "policies": {}, 1123 + "checkConstraints": {}, 1124 + "isRLSEnabled": false 1125 + }, 1126 + "public.community_settings": { 1127 + "name": "community_settings", 1128 + "schema": "", 1129 + "columns": { 1130 + "community_did": { 1131 + "name": "community_did", 1132 + "type": "text", 1133 + "primaryKey": true, 1134 + "notNull": true 1135 + }, 1136 + "domains": { 1137 + "name": "domains", 1138 + "type": "jsonb", 1139 + "primaryKey": false, 1140 + "notNull": true, 1141 + "default": "'[]'::jsonb" 1142 + }, 1143 + "initialized": { 1144 + "name": "initialized", 1145 + "type": "boolean", 1146 + "primaryKey": false, 1147 + "notNull": true, 1148 + "default": false 1149 + }, 1150 + "admin_did": { 1151 + "name": "admin_did", 1152 + "type": "text", 1153 + "primaryKey": false, 1154 + "notNull": false 1155 + }, 1156 + "community_name": { 1157 + "name": "community_name", 1158 + "type": "text", 1159 + "primaryKey": false, 1160 + "notNull": true, 1161 + "default": "'Barazo Community'" 1162 + }, 1163 + "maturity_rating": { 1164 + "name": "maturity_rating", 1165 + "type": "text", 1166 + "primaryKey": false, 1167 + "notNull": true, 1168 + "default": "'safe'" 1169 + }, 1170 + "reaction_set": { 1171 + "name": "reaction_set", 1172 + "type": "jsonb", 1173 + "primaryKey": false, 1174 + "notNull": true, 1175 + "default": "'[\"like\"]'::jsonb" 1176 + }, 1177 + "moderation_thresholds": { 1178 + "name": "moderation_thresholds", 1179 + "type": "jsonb", 1180 + "primaryKey": false, 1181 + "notNull": true, 1182 + "default": "'{\"autoBlockReportCount\":5,\"warnThreshold\":3,\"firstPostQueueCount\":3,\"newAccountDays\":7,\"newAccountWriteRatePerMin\":3,\"establishedWriteRatePerMin\":10,\"linkHoldEnabled\":true,\"topicCreationDelayEnabled\":true,\"burstPostCount\":5,\"burstWindowMinutes\":10,\"trustedPostThreshold\":10}'::jsonb" 1183 + }, 1184 + "word_filter": { 1185 + "name": "word_filter", 1186 + "type": "jsonb", 1187 + "primaryKey": false, 1188 + "notNull": true, 1189 + "default": "'[]'::jsonb" 1190 + }, 1191 + "jurisdiction_country": { 1192 + "name": "jurisdiction_country", 1193 + "type": "text", 1194 + "primaryKey": false, 1195 + "notNull": false 1196 + }, 1197 + "age_threshold": { 1198 + "name": "age_threshold", 1199 + "type": "integer", 1200 + "primaryKey": false, 1201 + "notNull": true, 1202 + "default": 16 1203 + }, 1204 + "require_login_for_mature": { 1205 + "name": "require_login_for_mature", 1206 + "type": "boolean", 1207 + "primaryKey": false, 1208 + "notNull": true, 1209 + "default": true 1210 + }, 1211 + "community_description": { 1212 + "name": "community_description", 1213 + "type": "text", 1214 + "primaryKey": false, 1215 + "notNull": false 1216 + }, 1217 + "handle": { 1218 + "name": "handle", 1219 + "type": "text", 1220 + "primaryKey": false, 1221 + "notNull": false 1222 + }, 1223 + "service_endpoint": { 1224 + "name": "service_endpoint", 1225 + "type": "text", 1226 + "primaryKey": false, 1227 + "notNull": false 1228 + }, 1229 + "signing_key": { 1230 + "name": "signing_key", 1231 + "type": "text", 1232 + "primaryKey": false, 1233 + "notNull": false 1234 + }, 1235 + "rotation_key": { 1236 + "name": "rotation_key", 1237 + "type": "text", 1238 + "primaryKey": false, 1239 + "notNull": false 1240 + }, 1241 + "community_logo_url": { 1242 + "name": "community_logo_url", 1243 + "type": "text", 1244 + "primaryKey": false, 1245 + "notNull": false 1246 + }, 1247 + "favicon_url": { 1248 + "name": "favicon_url", 1249 + "type": "text", 1250 + "primaryKey": false, 1251 + "notNull": false 1252 + }, 1253 + "primary_color": { 1254 + "name": "primary_color", 1255 + "type": "text", 1256 + "primaryKey": false, 1257 + "notNull": false 1258 + }, 1259 + "accent_color": { 1260 + "name": "accent_color", 1261 + "type": "text", 1262 + "primaryKey": false, 1263 + "notNull": false 1264 + }, 1265 + "created_at": { 1266 + "name": "created_at", 1267 + "type": "timestamp with time zone", 1268 + "primaryKey": false, 1269 + "notNull": true, 1270 + "default": "now()" 1271 + }, 1272 + "updated_at": { 1273 + "name": "updated_at", 1274 + "type": "timestamp with time zone", 1275 + "primaryKey": false, 1276 + "notNull": true, 1277 + "default": "now()" 1278 + } 1279 + }, 1280 + "indexes": {}, 1281 + "foreignKeys": {}, 1282 + "compositePrimaryKeys": {}, 1283 + "uniqueConstraints": {}, 1284 + "policies": { 1285 + "tenant_isolation": { 1286 + "name": "tenant_isolation", 1287 + "as": "PERMISSIVE", 1288 + "for": "ALL", 1289 + "to": [ 1290 + "barazo_app" 1291 + ], 1292 + "using": "community_did = current_setting('app.current_community_did', true)", 1293 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 1294 + } 1295 + }, 1296 + "checkConstraints": {}, 1297 + "isRLSEnabled": true 1298 + }, 1299 + "public.categories": { 1300 + "name": "categories", 1301 + "schema": "", 1302 + "columns": { 1303 + "id": { 1304 + "name": "id", 1305 + "type": "text", 1306 + "primaryKey": true, 1307 + "notNull": true 1308 + }, 1309 + "slug": { 1310 + "name": "slug", 1311 + "type": "text", 1312 + "primaryKey": false, 1313 + "notNull": true 1314 + }, 1315 + "name": { 1316 + "name": "name", 1317 + "type": "text", 1318 + "primaryKey": false, 1319 + "notNull": true 1320 + }, 1321 + "description": { 1322 + "name": "description", 1323 + "type": "text", 1324 + "primaryKey": false, 1325 + "notNull": false 1326 + }, 1327 + "parent_id": { 1328 + "name": "parent_id", 1329 + "type": "text", 1330 + "primaryKey": false, 1331 + "notNull": false 1332 + }, 1333 + "sort_order": { 1334 + "name": "sort_order", 1335 + "type": "integer", 1336 + "primaryKey": false, 1337 + "notNull": true, 1338 + "default": 0 1339 + }, 1340 + "community_did": { 1341 + "name": "community_did", 1342 + "type": "text", 1343 + "primaryKey": false, 1344 + "notNull": true 1345 + }, 1346 + "maturity_rating": { 1347 + "name": "maturity_rating", 1348 + "type": "text", 1349 + "primaryKey": false, 1350 + "notNull": true, 1351 + "default": "'safe'" 1352 + }, 1353 + "created_at": { 1354 + "name": "created_at", 1355 + "type": "timestamp with time zone", 1356 + "primaryKey": false, 1357 + "notNull": true, 1358 + "default": "now()" 1359 + }, 1360 + "updated_at": { 1361 + "name": "updated_at", 1362 + "type": "timestamp with time zone", 1363 + "primaryKey": false, 1364 + "notNull": true, 1365 + "default": "now()" 1366 + } 1367 + }, 1368 + "indexes": { 1369 + "categories_slug_community_did_idx": { 1370 + "name": "categories_slug_community_did_idx", 1371 + "columns": [ 1372 + { 1373 + "expression": "slug", 1374 + "isExpression": false, 1375 + "asc": true, 1376 + "nulls": "last" 1377 + }, 1378 + { 1379 + "expression": "community_did", 1380 + "isExpression": false, 1381 + "asc": true, 1382 + "nulls": "last" 1383 + } 1384 + ], 1385 + "isUnique": true, 1386 + "concurrently": false, 1387 + "method": "btree", 1388 + "with": {} 1389 + }, 1390 + "categories_parent_id_idx": { 1391 + "name": "categories_parent_id_idx", 1392 + "columns": [ 1393 + { 1394 + "expression": "parent_id", 1395 + "isExpression": false, 1396 + "asc": true, 1397 + "nulls": "last" 1398 + } 1399 + ], 1400 + "isUnique": false, 1401 + "concurrently": false, 1402 + "method": "btree", 1403 + "with": {} 1404 + }, 1405 + "categories_community_did_idx": { 1406 + "name": "categories_community_did_idx", 1407 + "columns": [ 1408 + { 1409 + "expression": "community_did", 1410 + "isExpression": false, 1411 + "asc": true, 1412 + "nulls": "last" 1413 + } 1414 + ], 1415 + "isUnique": false, 1416 + "concurrently": false, 1417 + "method": "btree", 1418 + "with": {} 1419 + }, 1420 + "categories_maturity_rating_idx": { 1421 + "name": "categories_maturity_rating_idx", 1422 + "columns": [ 1423 + { 1424 + "expression": "maturity_rating", 1425 + "isExpression": false, 1426 + "asc": true, 1427 + "nulls": "last" 1428 + } 1429 + ], 1430 + "isUnique": false, 1431 + "concurrently": false, 1432 + "method": "btree", 1433 + "with": {} 1434 + } 1435 + }, 1436 + "foreignKeys": { 1437 + "categories_parent_id_fk": { 1438 + "name": "categories_parent_id_fk", 1439 + "tableFrom": "categories", 1440 + "tableTo": "categories", 1441 + "columnsFrom": [ 1442 + "parent_id" 1443 + ], 1444 + "columnsTo": [ 1445 + "id" 1446 + ], 1447 + "onDelete": "set null", 1448 + "onUpdate": "no action" 1449 + } 1450 + }, 1451 + "compositePrimaryKeys": {}, 1452 + "uniqueConstraints": {}, 1453 + "policies": { 1454 + "tenant_isolation": { 1455 + "name": "tenant_isolation", 1456 + "as": "PERMISSIVE", 1457 + "for": "ALL", 1458 + "to": [ 1459 + "barazo_app" 1460 + ], 1461 + "using": "community_did = current_setting('app.current_community_did', true)", 1462 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 1463 + } 1464 + }, 1465 + "checkConstraints": {}, 1466 + "isRLSEnabled": true 1467 + }, 1468 + "public.moderation_actions": { 1469 + "name": "moderation_actions", 1470 + "schema": "", 1471 + "columns": { 1472 + "id": { 1473 + "name": "id", 1474 + "type": "serial", 1475 + "primaryKey": true, 1476 + "notNull": true 1477 + }, 1478 + "action": { 1479 + "name": "action", 1480 + "type": "text", 1481 + "primaryKey": false, 1482 + "notNull": true 1483 + }, 1484 + "target_uri": { 1485 + "name": "target_uri", 1486 + "type": "text", 1487 + "primaryKey": false, 1488 + "notNull": false 1489 + }, 1490 + "target_did": { 1491 + "name": "target_did", 1492 + "type": "text", 1493 + "primaryKey": false, 1494 + "notNull": false 1495 + }, 1496 + "moderator_did": { 1497 + "name": "moderator_did", 1498 + "type": "text", 1499 + "primaryKey": false, 1500 + "notNull": true 1501 + }, 1502 + "community_did": { 1503 + "name": "community_did", 1504 + "type": "text", 1505 + "primaryKey": false, 1506 + "notNull": true 1507 + }, 1508 + "reason": { 1509 + "name": "reason", 1510 + "type": "text", 1511 + "primaryKey": false, 1512 + "notNull": false 1513 + }, 1514 + "created_at": { 1515 + "name": "created_at", 1516 + "type": "timestamp with time zone", 1517 + "primaryKey": false, 1518 + "notNull": true, 1519 + "default": "now()" 1520 + } 1521 + }, 1522 + "indexes": { 1523 + "mod_actions_moderator_did_idx": { 1524 + "name": "mod_actions_moderator_did_idx", 1525 + "columns": [ 1526 + { 1527 + "expression": "moderator_did", 1528 + "isExpression": false, 1529 + "asc": true, 1530 + "nulls": "last" 1531 + } 1532 + ], 1533 + "isUnique": false, 1534 + "concurrently": false, 1535 + "method": "btree", 1536 + "with": {} 1537 + }, 1538 + "mod_actions_community_did_idx": { 1539 + "name": "mod_actions_community_did_idx", 1540 + "columns": [ 1541 + { 1542 + "expression": "community_did", 1543 + "isExpression": false, 1544 + "asc": true, 1545 + "nulls": "last" 1546 + } 1547 + ], 1548 + "isUnique": false, 1549 + "concurrently": false, 1550 + "method": "btree", 1551 + "with": {} 1552 + }, 1553 + "mod_actions_created_at_idx": { 1554 + "name": "mod_actions_created_at_idx", 1555 + "columns": [ 1556 + { 1557 + "expression": "created_at", 1558 + "isExpression": false, 1559 + "asc": true, 1560 + "nulls": "last" 1561 + } 1562 + ], 1563 + "isUnique": false, 1564 + "concurrently": false, 1565 + "method": "btree", 1566 + "with": {} 1567 + }, 1568 + "mod_actions_target_uri_idx": { 1569 + "name": "mod_actions_target_uri_idx", 1570 + "columns": [ 1571 + { 1572 + "expression": "target_uri", 1573 + "isExpression": false, 1574 + "asc": true, 1575 + "nulls": "last" 1576 + } 1577 + ], 1578 + "isUnique": false, 1579 + "concurrently": false, 1580 + "method": "btree", 1581 + "with": {} 1582 + }, 1583 + "mod_actions_target_did_idx": { 1584 + "name": "mod_actions_target_did_idx", 1585 + "columns": [ 1586 + { 1587 + "expression": "target_did", 1588 + "isExpression": false, 1589 + "asc": true, 1590 + "nulls": "last" 1591 + } 1592 + ], 1593 + "isUnique": false, 1594 + "concurrently": false, 1595 + "method": "btree", 1596 + "with": {} 1597 + } 1598 + }, 1599 + "foreignKeys": {}, 1600 + "compositePrimaryKeys": {}, 1601 + "uniqueConstraints": {}, 1602 + "policies": { 1603 + "tenant_isolation": { 1604 + "name": "tenant_isolation", 1605 + "as": "PERMISSIVE", 1606 + "for": "ALL", 1607 + "to": [ 1608 + "barazo_app" 1609 + ], 1610 + "using": "community_did = current_setting('app.current_community_did', true)", 1611 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 1612 + } 1613 + }, 1614 + "checkConstraints": {}, 1615 + "isRLSEnabled": true 1616 + }, 1617 + "public.reports": { 1618 + "name": "reports", 1619 + "schema": "", 1620 + "columns": { 1621 + "id": { 1622 + "name": "id", 1623 + "type": "serial", 1624 + "primaryKey": true, 1625 + "notNull": true 1626 + }, 1627 + "reporter_did": { 1628 + "name": "reporter_did", 1629 + "type": "text", 1630 + "primaryKey": false, 1631 + "notNull": true 1632 + }, 1633 + "target_uri": { 1634 + "name": "target_uri", 1635 + "type": "text", 1636 + "primaryKey": false, 1637 + "notNull": true 1638 + }, 1639 + "target_did": { 1640 + "name": "target_did", 1641 + "type": "text", 1642 + "primaryKey": false, 1643 + "notNull": true 1644 + }, 1645 + "reason_type": { 1646 + "name": "reason_type", 1647 + "type": "text", 1648 + "primaryKey": false, 1649 + "notNull": true 1650 + }, 1651 + "description": { 1652 + "name": "description", 1653 + "type": "text", 1654 + "primaryKey": false, 1655 + "notNull": false 1656 + }, 1657 + "community_did": { 1658 + "name": "community_did", 1659 + "type": "text", 1660 + "primaryKey": false, 1661 + "notNull": true 1662 + }, 1663 + "status": { 1664 + "name": "status", 1665 + "type": "text", 1666 + "primaryKey": false, 1667 + "notNull": true, 1668 + "default": "'pending'" 1669 + }, 1670 + "resolution_type": { 1671 + "name": "resolution_type", 1672 + "type": "text", 1673 + "primaryKey": false, 1674 + "notNull": false 1675 + }, 1676 + "resolved_by": { 1677 + "name": "resolved_by", 1678 + "type": "text", 1679 + "primaryKey": false, 1680 + "notNull": false 1681 + }, 1682 + "resolved_at": { 1683 + "name": "resolved_at", 1684 + "type": "timestamp with time zone", 1685 + "primaryKey": false, 1686 + "notNull": false 1687 + }, 1688 + "appeal_reason": { 1689 + "name": "appeal_reason", 1690 + "type": "text", 1691 + "primaryKey": false, 1692 + "notNull": false 1693 + }, 1694 + "appealed_at": { 1695 + "name": "appealed_at", 1696 + "type": "timestamp with time zone", 1697 + "primaryKey": false, 1698 + "notNull": false 1699 + }, 1700 + "appeal_status": { 1701 + "name": "appeal_status", 1702 + "type": "text", 1703 + "primaryKey": false, 1704 + "notNull": true, 1705 + "default": "'none'" 1706 + }, 1707 + "created_at": { 1708 + "name": "created_at", 1709 + "type": "timestamp with time zone", 1710 + "primaryKey": false, 1711 + "notNull": true, 1712 + "default": "now()" 1713 + } 1714 + }, 1715 + "indexes": { 1716 + "reports_reporter_did_idx": { 1717 + "name": "reports_reporter_did_idx", 1718 + "columns": [ 1719 + { 1720 + "expression": "reporter_did", 1721 + "isExpression": false, 1722 + "asc": true, 1723 + "nulls": "last" 1724 + } 1725 + ], 1726 + "isUnique": false, 1727 + "concurrently": false, 1728 + "method": "btree", 1729 + "with": {} 1730 + }, 1731 + "reports_target_uri_idx": { 1732 + "name": "reports_target_uri_idx", 1733 + "columns": [ 1734 + { 1735 + "expression": "target_uri", 1736 + "isExpression": false, 1737 + "asc": true, 1738 + "nulls": "last" 1739 + } 1740 + ], 1741 + "isUnique": false, 1742 + "concurrently": false, 1743 + "method": "btree", 1744 + "with": {} 1745 + }, 1746 + "reports_target_did_idx": { 1747 + "name": "reports_target_did_idx", 1748 + "columns": [ 1749 + { 1750 + "expression": "target_did", 1751 + "isExpression": false, 1752 + "asc": true, 1753 + "nulls": "last" 1754 + } 1755 + ], 1756 + "isUnique": false, 1757 + "concurrently": false, 1758 + "method": "btree", 1759 + "with": {} 1760 + }, 1761 + "reports_community_did_idx": { 1762 + "name": "reports_community_did_idx", 1763 + "columns": [ 1764 + { 1765 + "expression": "community_did", 1766 + "isExpression": false, 1767 + "asc": true, 1768 + "nulls": "last" 1769 + } 1770 + ], 1771 + "isUnique": false, 1772 + "concurrently": false, 1773 + "method": "btree", 1774 + "with": {} 1775 + }, 1776 + "reports_status_idx": { 1777 + "name": "reports_status_idx", 1778 + "columns": [ 1779 + { 1780 + "expression": "status", 1781 + "isExpression": false, 1782 + "asc": true, 1783 + "nulls": "last" 1784 + } 1785 + ], 1786 + "isUnique": false, 1787 + "concurrently": false, 1788 + "method": "btree", 1789 + "with": {} 1790 + }, 1791 + "reports_created_at_idx": { 1792 + "name": "reports_created_at_idx", 1793 + "columns": [ 1794 + { 1795 + "expression": "created_at", 1796 + "isExpression": false, 1797 + "asc": true, 1798 + "nulls": "last" 1799 + } 1800 + ], 1801 + "isUnique": false, 1802 + "concurrently": false, 1803 + "method": "btree", 1804 + "with": {} 1805 + }, 1806 + "reports_unique_reporter_target_idx": { 1807 + "name": "reports_unique_reporter_target_idx", 1808 + "columns": [ 1809 + { 1810 + "expression": "reporter_did", 1811 + "isExpression": false, 1812 + "asc": true, 1813 + "nulls": "last" 1814 + }, 1815 + { 1816 + "expression": "target_uri", 1817 + "isExpression": false, 1818 + "asc": true, 1819 + "nulls": "last" 1820 + }, 1821 + { 1822 + "expression": "community_did", 1823 + "isExpression": false, 1824 + "asc": true, 1825 + "nulls": "last" 1826 + } 1827 + ], 1828 + "isUnique": true, 1829 + "concurrently": false, 1830 + "method": "btree", 1831 + "with": {} 1832 + } 1833 + }, 1834 + "foreignKeys": {}, 1835 + "compositePrimaryKeys": {}, 1836 + "uniqueConstraints": {}, 1837 + "policies": { 1838 + "tenant_isolation": { 1839 + "name": "tenant_isolation", 1840 + "as": "PERMISSIVE", 1841 + "for": "ALL", 1842 + "to": [ 1843 + "barazo_app" 1844 + ], 1845 + "using": "community_did = current_setting('app.current_community_did', true)", 1846 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 1847 + } 1848 + }, 1849 + "checkConstraints": {}, 1850 + "isRLSEnabled": true 1851 + }, 1852 + "public.notifications": { 1853 + "name": "notifications", 1854 + "schema": "", 1855 + "columns": { 1856 + "id": { 1857 + "name": "id", 1858 + "type": "serial", 1859 + "primaryKey": true, 1860 + "notNull": true 1861 + }, 1862 + "recipient_did": { 1863 + "name": "recipient_did", 1864 + "type": "text", 1865 + "primaryKey": false, 1866 + "notNull": true 1867 + }, 1868 + "type": { 1869 + "name": "type", 1870 + "type": "text", 1871 + "primaryKey": false, 1872 + "notNull": true 1873 + }, 1874 + "subject_uri": { 1875 + "name": "subject_uri", 1876 + "type": "text", 1877 + "primaryKey": false, 1878 + "notNull": true 1879 + }, 1880 + "actor_did": { 1881 + "name": "actor_did", 1882 + "type": "text", 1883 + "primaryKey": false, 1884 + "notNull": true 1885 + }, 1886 + "community_did": { 1887 + "name": "community_did", 1888 + "type": "text", 1889 + "primaryKey": false, 1890 + "notNull": true 1891 + }, 1892 + "read": { 1893 + "name": "read", 1894 + "type": "boolean", 1895 + "primaryKey": false, 1896 + "notNull": true, 1897 + "default": false 1898 + }, 1899 + "created_at": { 1900 + "name": "created_at", 1901 + "type": "timestamp with time zone", 1902 + "primaryKey": false, 1903 + "notNull": true, 1904 + "default": "now()" 1905 + } 1906 + }, 1907 + "indexes": { 1908 + "notifications_recipient_did_idx": { 1909 + "name": "notifications_recipient_did_idx", 1910 + "columns": [ 1911 + { 1912 + "expression": "recipient_did", 1913 + "isExpression": false, 1914 + "asc": true, 1915 + "nulls": "last" 1916 + } 1917 + ], 1918 + "isUnique": false, 1919 + "concurrently": false, 1920 + "method": "btree", 1921 + "with": {} 1922 + }, 1923 + "notifications_recipient_read_idx": { 1924 + "name": "notifications_recipient_read_idx", 1925 + "columns": [ 1926 + { 1927 + "expression": "recipient_did", 1928 + "isExpression": false, 1929 + "asc": true, 1930 + "nulls": "last" 1931 + }, 1932 + { 1933 + "expression": "read", 1934 + "isExpression": false, 1935 + "asc": true, 1936 + "nulls": "last" 1937 + } 1938 + ], 1939 + "isUnique": false, 1940 + "concurrently": false, 1941 + "method": "btree", 1942 + "with": {} 1943 + }, 1944 + "notifications_created_at_idx": { 1945 + "name": "notifications_created_at_idx", 1946 + "columns": [ 1947 + { 1948 + "expression": "created_at", 1949 + "isExpression": false, 1950 + "asc": true, 1951 + "nulls": "last" 1952 + } 1953 + ], 1954 + "isUnique": false, 1955 + "concurrently": false, 1956 + "method": "btree", 1957 + "with": {} 1958 + } 1959 + }, 1960 + "foreignKeys": {}, 1961 + "compositePrimaryKeys": {}, 1962 + "uniqueConstraints": {}, 1963 + "policies": { 1964 + "tenant_isolation": { 1965 + "name": "tenant_isolation", 1966 + "as": "PERMISSIVE", 1967 + "for": "ALL", 1968 + "to": [ 1969 + "barazo_app" 1970 + ], 1971 + "using": "community_did = current_setting('app.current_community_did', true)", 1972 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 1973 + } 1974 + }, 1975 + "checkConstraints": {}, 1976 + "isRLSEnabled": true 1977 + }, 1978 + "public.user_community_preferences": { 1979 + "name": "user_community_preferences", 1980 + "schema": "", 1981 + "columns": { 1982 + "did": { 1983 + "name": "did", 1984 + "type": "text", 1985 + "primaryKey": false, 1986 + "notNull": true 1987 + }, 1988 + "community_did": { 1989 + "name": "community_did", 1990 + "type": "text", 1991 + "primaryKey": false, 1992 + "notNull": true 1993 + }, 1994 + "maturity_override": { 1995 + "name": "maturity_override", 1996 + "type": "text", 1997 + "primaryKey": false, 1998 + "notNull": false 1999 + }, 2000 + "muted_words": { 2001 + "name": "muted_words", 2002 + "type": "jsonb", 2003 + "primaryKey": false, 2004 + "notNull": false 2005 + }, 2006 + "blocked_dids": { 2007 + "name": "blocked_dids", 2008 + "type": "jsonb", 2009 + "primaryKey": false, 2010 + "notNull": false 2011 + }, 2012 + "muted_dids": { 2013 + "name": "muted_dids", 2014 + "type": "jsonb", 2015 + "primaryKey": false, 2016 + "notNull": false 2017 + }, 2018 + "notification_prefs": { 2019 + "name": "notification_prefs", 2020 + "type": "jsonb", 2021 + "primaryKey": false, 2022 + "notNull": false 2023 + }, 2024 + "updated_at": { 2025 + "name": "updated_at", 2026 + "type": "timestamp with time zone", 2027 + "primaryKey": false, 2028 + "notNull": true, 2029 + "default": "now()" 2030 + } 2031 + }, 2032 + "indexes": { 2033 + "user_community_prefs_did_idx": { 2034 + "name": "user_community_prefs_did_idx", 2035 + "columns": [ 2036 + { 2037 + "expression": "did", 2038 + "isExpression": false, 2039 + "asc": true, 2040 + "nulls": "last" 2041 + } 2042 + ], 2043 + "isUnique": false, 2044 + "concurrently": false, 2045 + "method": "btree", 2046 + "with": {} 2047 + }, 2048 + "user_community_prefs_community_idx": { 2049 + "name": "user_community_prefs_community_idx", 2050 + "columns": [ 2051 + { 2052 + "expression": "community_did", 2053 + "isExpression": false, 2054 + "asc": true, 2055 + "nulls": "last" 2056 + } 2057 + ], 2058 + "isUnique": false, 2059 + "concurrently": false, 2060 + "method": "btree", 2061 + "with": {} 2062 + } 2063 + }, 2064 + "foreignKeys": {}, 2065 + "compositePrimaryKeys": { 2066 + "user_community_preferences_did_community_did_pk": { 2067 + "name": "user_community_preferences_did_community_did_pk", 2068 + "columns": [ 2069 + "did", 2070 + "community_did" 2071 + ] 2072 + } 2073 + }, 2074 + "uniqueConstraints": {}, 2075 + "policies": { 2076 + "tenant_isolation": { 2077 + "name": "tenant_isolation", 2078 + "as": "PERMISSIVE", 2079 + "for": "ALL", 2080 + "to": [ 2081 + "barazo_app" 2082 + ], 2083 + "using": "community_did = current_setting('app.current_community_did', true)", 2084 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 2085 + } 2086 + }, 2087 + "checkConstraints": {}, 2088 + "isRLSEnabled": true 2089 + }, 2090 + "public.user_preferences": { 2091 + "name": "user_preferences", 2092 + "schema": "", 2093 + "columns": { 2094 + "did": { 2095 + "name": "did", 2096 + "type": "text", 2097 + "primaryKey": true, 2098 + "notNull": true 2099 + }, 2100 + "maturity_level": { 2101 + "name": "maturity_level", 2102 + "type": "text", 2103 + "primaryKey": false, 2104 + "notNull": true, 2105 + "default": "'sfw'" 2106 + }, 2107 + "declared_age": { 2108 + "name": "declared_age", 2109 + "type": "integer", 2110 + "primaryKey": false, 2111 + "notNull": false 2112 + }, 2113 + "muted_words": { 2114 + "name": "muted_words", 2115 + "type": "jsonb", 2116 + "primaryKey": false, 2117 + "notNull": true, 2118 + "default": "'[]'::jsonb" 2119 + }, 2120 + "blocked_dids": { 2121 + "name": "blocked_dids", 2122 + "type": "jsonb", 2123 + "primaryKey": false, 2124 + "notNull": true, 2125 + "default": "'[]'::jsonb" 2126 + }, 2127 + "muted_dids": { 2128 + "name": "muted_dids", 2129 + "type": "jsonb", 2130 + "primaryKey": false, 2131 + "notNull": true, 2132 + "default": "'[]'::jsonb" 2133 + }, 2134 + "cross_post_bluesky": { 2135 + "name": "cross_post_bluesky", 2136 + "type": "boolean", 2137 + "primaryKey": false, 2138 + "notNull": true, 2139 + "default": false 2140 + }, 2141 + "cross_post_frontpage": { 2142 + "name": "cross_post_frontpage", 2143 + "type": "boolean", 2144 + "primaryKey": false, 2145 + "notNull": true, 2146 + "default": false 2147 + }, 2148 + "cross_post_scopes_granted": { 2149 + "name": "cross_post_scopes_granted", 2150 + "type": "boolean", 2151 + "primaryKey": false, 2152 + "notNull": true, 2153 + "default": false 2154 + }, 2155 + "updated_at": { 2156 + "name": "updated_at", 2157 + "type": "timestamp with time zone", 2158 + "primaryKey": false, 2159 + "notNull": true, 2160 + "default": "now()" 2161 + } 2162 + }, 2163 + "indexes": {}, 2164 + "foreignKeys": {}, 2165 + "compositePrimaryKeys": {}, 2166 + "uniqueConstraints": {}, 2167 + "policies": {}, 2168 + "checkConstraints": {}, 2169 + "isRLSEnabled": false 2170 + }, 2171 + "public.cross_posts": { 2172 + "name": "cross_posts", 2173 + "schema": "", 2174 + "columns": { 2175 + "id": { 2176 + "name": "id", 2177 + "type": "text", 2178 + "primaryKey": true, 2179 + "notNull": true 2180 + }, 2181 + "topic_uri": { 2182 + "name": "topic_uri", 2183 + "type": "text", 2184 + "primaryKey": false, 2185 + "notNull": true 2186 + }, 2187 + "service": { 2188 + "name": "service", 2189 + "type": "text", 2190 + "primaryKey": false, 2191 + "notNull": true 2192 + }, 2193 + "cross_post_uri": { 2194 + "name": "cross_post_uri", 2195 + "type": "text", 2196 + "primaryKey": false, 2197 + "notNull": true 2198 + }, 2199 + "cross_post_cid": { 2200 + "name": "cross_post_cid", 2201 + "type": "text", 2202 + "primaryKey": false, 2203 + "notNull": true 2204 + }, 2205 + "author_did": { 2206 + "name": "author_did", 2207 + "type": "text", 2208 + "primaryKey": false, 2209 + "notNull": true 2210 + }, 2211 + "created_at": { 2212 + "name": "created_at", 2213 + "type": "timestamp with time zone", 2214 + "primaryKey": false, 2215 + "notNull": true, 2216 + "default": "now()" 2217 + } 2218 + }, 2219 + "indexes": { 2220 + "cross_posts_topic_uri_idx": { 2221 + "name": "cross_posts_topic_uri_idx", 2222 + "columns": [ 2223 + { 2224 + "expression": "topic_uri", 2225 + "isExpression": false, 2226 + "asc": true, 2227 + "nulls": "last" 2228 + } 2229 + ], 2230 + "isUnique": false, 2231 + "concurrently": false, 2232 + "method": "btree", 2233 + "with": {} 2234 + }, 2235 + "cross_posts_author_did_idx": { 2236 + "name": "cross_posts_author_did_idx", 2237 + "columns": [ 2238 + { 2239 + "expression": "author_did", 2240 + "isExpression": false, 2241 + "asc": true, 2242 + "nulls": "last" 2243 + } 2244 + ], 2245 + "isUnique": false, 2246 + "concurrently": false, 2247 + "method": "btree", 2248 + "with": {} 2249 + } 2250 + }, 2251 + "foreignKeys": {}, 2252 + "compositePrimaryKeys": {}, 2253 + "uniqueConstraints": {}, 2254 + "policies": {}, 2255 + "checkConstraints": {}, 2256 + "isRLSEnabled": false 2257 + }, 2258 + "public.community_onboarding_fields": { 2259 + "name": "community_onboarding_fields", 2260 + "schema": "", 2261 + "columns": { 2262 + "id": { 2263 + "name": "id", 2264 + "type": "text", 2265 + "primaryKey": true, 2266 + "notNull": true 2267 + }, 2268 + "community_did": { 2269 + "name": "community_did", 2270 + "type": "text", 2271 + "primaryKey": false, 2272 + "notNull": true 2273 + }, 2274 + "field_type": { 2275 + "name": "field_type", 2276 + "type": "text", 2277 + "primaryKey": false, 2278 + "notNull": true 2279 + }, 2280 + "label": { 2281 + "name": "label", 2282 + "type": "text", 2283 + "primaryKey": false, 2284 + "notNull": true 2285 + }, 2286 + "description": { 2287 + "name": "description", 2288 + "type": "text", 2289 + "primaryKey": false, 2290 + "notNull": false 2291 + }, 2292 + "is_mandatory": { 2293 + "name": "is_mandatory", 2294 + "type": "boolean", 2295 + "primaryKey": false, 2296 + "notNull": true, 2297 + "default": true 2298 + }, 2299 + "sort_order": { 2300 + "name": "sort_order", 2301 + "type": "integer", 2302 + "primaryKey": false, 2303 + "notNull": true, 2304 + "default": 0 2305 + }, 2306 + "source": { 2307 + "name": "source", 2308 + "type": "text", 2309 + "primaryKey": false, 2310 + "notNull": true, 2311 + "default": "'admin'" 2312 + }, 2313 + "config": { 2314 + "name": "config", 2315 + "type": "jsonb", 2316 + "primaryKey": false, 2317 + "notNull": false 2318 + }, 2319 + "created_at": { 2320 + "name": "created_at", 2321 + "type": "timestamp with time zone", 2322 + "primaryKey": false, 2323 + "notNull": true, 2324 + "default": "now()" 2325 + }, 2326 + "updated_at": { 2327 + "name": "updated_at", 2328 + "type": "timestamp with time zone", 2329 + "primaryKey": false, 2330 + "notNull": true, 2331 + "default": "now()" 2332 + } 2333 + }, 2334 + "indexes": { 2335 + "onboarding_fields_community_idx": { 2336 + "name": "onboarding_fields_community_idx", 2337 + "columns": [ 2338 + { 2339 + "expression": "community_did", 2340 + "isExpression": false, 2341 + "asc": true, 2342 + "nulls": "last" 2343 + } 2344 + ], 2345 + "isUnique": false, 2346 + "concurrently": false, 2347 + "method": "btree", 2348 + "with": {} 2349 + } 2350 + }, 2351 + "foreignKeys": {}, 2352 + "compositePrimaryKeys": {}, 2353 + "uniqueConstraints": {}, 2354 + "policies": { 2355 + "tenant_isolation": { 2356 + "name": "tenant_isolation", 2357 + "as": "PERMISSIVE", 2358 + "for": "ALL", 2359 + "to": [ 2360 + "barazo_app" 2361 + ], 2362 + "using": "community_did = current_setting('app.current_community_did', true)", 2363 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 2364 + } 2365 + }, 2366 + "checkConstraints": {}, 2367 + "isRLSEnabled": true 2368 + }, 2369 + "public.user_onboarding_responses": { 2370 + "name": "user_onboarding_responses", 2371 + "schema": "", 2372 + "columns": { 2373 + "did": { 2374 + "name": "did", 2375 + "type": "text", 2376 + "primaryKey": false, 2377 + "notNull": true 2378 + }, 2379 + "community_did": { 2380 + "name": "community_did", 2381 + "type": "text", 2382 + "primaryKey": false, 2383 + "notNull": true 2384 + }, 2385 + "field_id": { 2386 + "name": "field_id", 2387 + "type": "text", 2388 + "primaryKey": false, 2389 + "notNull": true 2390 + }, 2391 + "response": { 2392 + "name": "response", 2393 + "type": "jsonb", 2394 + "primaryKey": false, 2395 + "notNull": true 2396 + }, 2397 + "completed_at": { 2398 + "name": "completed_at", 2399 + "type": "timestamp with time zone", 2400 + "primaryKey": false, 2401 + "notNull": true, 2402 + "default": "now()" 2403 + } 2404 + }, 2405 + "indexes": { 2406 + "onboarding_responses_did_community_idx": { 2407 + "name": "onboarding_responses_did_community_idx", 2408 + "columns": [ 2409 + { 2410 + "expression": "did", 2411 + "isExpression": false, 2412 + "asc": true, 2413 + "nulls": "last" 2414 + }, 2415 + { 2416 + "expression": "community_did", 2417 + "isExpression": false, 2418 + "asc": true, 2419 + "nulls": "last" 2420 + } 2421 + ], 2422 + "isUnique": false, 2423 + "concurrently": false, 2424 + "method": "btree", 2425 + "with": {} 2426 + } 2427 + }, 2428 + "foreignKeys": {}, 2429 + "compositePrimaryKeys": { 2430 + "user_onboarding_responses_did_community_did_field_id_pk": { 2431 + "name": "user_onboarding_responses_did_community_did_field_id_pk", 2432 + "columns": [ 2433 + "did", 2434 + "community_did", 2435 + "field_id" 2436 + ] 2437 + } 2438 + }, 2439 + "uniqueConstraints": {}, 2440 + "policies": { 2441 + "tenant_isolation": { 2442 + "name": "tenant_isolation", 2443 + "as": "PERMISSIVE", 2444 + "for": "ALL", 2445 + "to": [ 2446 + "barazo_app" 2447 + ], 2448 + "using": "community_did = current_setting('app.current_community_did', true)", 2449 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 2450 + } 2451 + }, 2452 + "checkConstraints": {}, 2453 + "isRLSEnabled": true 2454 + }, 2455 + "public.moderation_queue": { 2456 + "name": "moderation_queue", 2457 + "schema": "", 2458 + "columns": { 2459 + "id": { 2460 + "name": "id", 2461 + "type": "serial", 2462 + "primaryKey": true, 2463 + "notNull": true 2464 + }, 2465 + "content_uri": { 2466 + "name": "content_uri", 2467 + "type": "text", 2468 + "primaryKey": false, 2469 + "notNull": true 2470 + }, 2471 + "content_type": { 2472 + "name": "content_type", 2473 + "type": "text", 2474 + "primaryKey": false, 2475 + "notNull": true 2476 + }, 2477 + "author_did": { 2478 + "name": "author_did", 2479 + "type": "text", 2480 + "primaryKey": false, 2481 + "notNull": true 2482 + }, 2483 + "community_did": { 2484 + "name": "community_did", 2485 + "type": "text", 2486 + "primaryKey": false, 2487 + "notNull": true 2488 + }, 2489 + "queue_reason": { 2490 + "name": "queue_reason", 2491 + "type": "text", 2492 + "primaryKey": false, 2493 + "notNull": true 2494 + }, 2495 + "matched_words": { 2496 + "name": "matched_words", 2497 + "type": "jsonb", 2498 + "primaryKey": false, 2499 + "notNull": false 2500 + }, 2501 + "status": { 2502 + "name": "status", 2503 + "type": "text", 2504 + "primaryKey": false, 2505 + "notNull": true, 2506 + "default": "'pending'" 2507 + }, 2508 + "reviewed_by": { 2509 + "name": "reviewed_by", 2510 + "type": "text", 2511 + "primaryKey": false, 2512 + "notNull": false 2513 + }, 2514 + "created_at": { 2515 + "name": "created_at", 2516 + "type": "timestamp with time zone", 2517 + "primaryKey": false, 2518 + "notNull": true, 2519 + "default": "now()" 2520 + }, 2521 + "reviewed_at": { 2522 + "name": "reviewed_at", 2523 + "type": "timestamp with time zone", 2524 + "primaryKey": false, 2525 + "notNull": false 2526 + } 2527 + }, 2528 + "indexes": { 2529 + "mod_queue_author_did_idx": { 2530 + "name": "mod_queue_author_did_idx", 2531 + "columns": [ 2532 + { 2533 + "expression": "author_did", 2534 + "isExpression": false, 2535 + "asc": true, 2536 + "nulls": "last" 2537 + } 2538 + ], 2539 + "isUnique": false, 2540 + "concurrently": false, 2541 + "method": "btree", 2542 + "with": {} 2543 + }, 2544 + "mod_queue_community_did_idx": { 2545 + "name": "mod_queue_community_did_idx", 2546 + "columns": [ 2547 + { 2548 + "expression": "community_did", 2549 + "isExpression": false, 2550 + "asc": true, 2551 + "nulls": "last" 2552 + } 2553 + ], 2554 + "isUnique": false, 2555 + "concurrently": false, 2556 + "method": "btree", 2557 + "with": {} 2558 + }, 2559 + "mod_queue_status_idx": { 2560 + "name": "mod_queue_status_idx", 2561 + "columns": [ 2562 + { 2563 + "expression": "status", 2564 + "isExpression": false, 2565 + "asc": true, 2566 + "nulls": "last" 2567 + } 2568 + ], 2569 + "isUnique": false, 2570 + "concurrently": false, 2571 + "method": "btree", 2572 + "with": {} 2573 + }, 2574 + "mod_queue_created_at_idx": { 2575 + "name": "mod_queue_created_at_idx", 2576 + "columns": [ 2577 + { 2578 + "expression": "created_at", 2579 + "isExpression": false, 2580 + "asc": true, 2581 + "nulls": "last" 2582 + } 2583 + ], 2584 + "isUnique": false, 2585 + "concurrently": false, 2586 + "method": "btree", 2587 + "with": {} 2588 + }, 2589 + "mod_queue_content_uri_idx": { 2590 + "name": "mod_queue_content_uri_idx", 2591 + "columns": [ 2592 + { 2593 + "expression": "content_uri", 2594 + "isExpression": false, 2595 + "asc": true, 2596 + "nulls": "last" 2597 + } 2598 + ], 2599 + "isUnique": false, 2600 + "concurrently": false, 2601 + "method": "btree", 2602 + "with": {} 2603 + } 2604 + }, 2605 + "foreignKeys": {}, 2606 + "compositePrimaryKeys": {}, 2607 + "uniqueConstraints": {}, 2608 + "policies": { 2609 + "tenant_isolation": { 2610 + "name": "tenant_isolation", 2611 + "as": "PERMISSIVE", 2612 + "for": "ALL", 2613 + "to": [ 2614 + "barazo_app" 2615 + ], 2616 + "using": "community_did = current_setting('app.current_community_did', true)", 2617 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 2618 + } 2619 + }, 2620 + "checkConstraints": {}, 2621 + "isRLSEnabled": true 2622 + }, 2623 + "public.account_trust": { 2624 + "name": "account_trust", 2625 + "schema": "", 2626 + "columns": { 2627 + "id": { 2628 + "name": "id", 2629 + "type": "serial", 2630 + "primaryKey": true, 2631 + "notNull": true 2632 + }, 2633 + "did": { 2634 + "name": "did", 2635 + "type": "text", 2636 + "primaryKey": false, 2637 + "notNull": true 2638 + }, 2639 + "community_did": { 2640 + "name": "community_did", 2641 + "type": "text", 2642 + "primaryKey": false, 2643 + "notNull": true 2644 + }, 2645 + "approved_post_count": { 2646 + "name": "approved_post_count", 2647 + "type": "integer", 2648 + "primaryKey": false, 2649 + "notNull": true, 2650 + "default": 0 2651 + }, 2652 + "is_trusted": { 2653 + "name": "is_trusted", 2654 + "type": "boolean", 2655 + "primaryKey": false, 2656 + "notNull": true, 2657 + "default": false 2658 + }, 2659 + "trusted_at": { 2660 + "name": "trusted_at", 2661 + "type": "timestamp with time zone", 2662 + "primaryKey": false, 2663 + "notNull": false 2664 + } 2665 + }, 2666 + "indexes": { 2667 + "account_trust_did_community_idx": { 2668 + "name": "account_trust_did_community_idx", 2669 + "columns": [ 2670 + { 2671 + "expression": "did", 2672 + "isExpression": false, 2673 + "asc": true, 2674 + "nulls": "last" 2675 + }, 2676 + { 2677 + "expression": "community_did", 2678 + "isExpression": false, 2679 + "asc": true, 2680 + "nulls": "last" 2681 + } 2682 + ], 2683 + "isUnique": true, 2684 + "concurrently": false, 2685 + "method": "btree", 2686 + "with": {} 2687 + }, 2688 + "account_trust_did_idx": { 2689 + "name": "account_trust_did_idx", 2690 + "columns": [ 2691 + { 2692 + "expression": "did", 2693 + "isExpression": false, 2694 + "asc": true, 2695 + "nulls": "last" 2696 + } 2697 + ], 2698 + "isUnique": false, 2699 + "concurrently": false, 2700 + "method": "btree", 2701 + "with": {} 2702 + } 2703 + }, 2704 + "foreignKeys": {}, 2705 + "compositePrimaryKeys": {}, 2706 + "uniqueConstraints": {}, 2707 + "policies": { 2708 + "tenant_isolation": { 2709 + "name": "tenant_isolation", 2710 + "as": "PERMISSIVE", 2711 + "for": "ALL", 2712 + "to": [ 2713 + "barazo_app" 2714 + ], 2715 + "using": "community_did = current_setting('app.current_community_did', true)", 2716 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 2717 + } 2718 + }, 2719 + "checkConstraints": {}, 2720 + "isRLSEnabled": true 2721 + }, 2722 + "public.community_filters": { 2723 + "name": "community_filters", 2724 + "schema": "", 2725 + "columns": { 2726 + "community_did": { 2727 + "name": "community_did", 2728 + "type": "text", 2729 + "primaryKey": true, 2730 + "notNull": true 2731 + }, 2732 + "status": { 2733 + "name": "status", 2734 + "type": "text", 2735 + "primaryKey": false, 2736 + "notNull": true, 2737 + "default": "'active'" 2738 + }, 2739 + "admin_did": { 2740 + "name": "admin_did", 2741 + "type": "text", 2742 + "primaryKey": false, 2743 + "notNull": false 2744 + }, 2745 + "reason": { 2746 + "name": "reason", 2747 + "type": "text", 2748 + "primaryKey": false, 2749 + "notNull": false 2750 + }, 2751 + "report_count": { 2752 + "name": "report_count", 2753 + "type": "integer", 2754 + "primaryKey": false, 2755 + "notNull": true, 2756 + "default": 0 2757 + }, 2758 + "last_reviewed_at": { 2759 + "name": "last_reviewed_at", 2760 + "type": "timestamp with time zone", 2761 + "primaryKey": false, 2762 + "notNull": false 2763 + }, 2764 + "filtered_by": { 2765 + "name": "filtered_by", 2766 + "type": "text", 2767 + "primaryKey": false, 2768 + "notNull": false 2769 + }, 2770 + "created_at": { 2771 + "name": "created_at", 2772 + "type": "timestamp with time zone", 2773 + "primaryKey": false, 2774 + "notNull": true, 2775 + "default": "now()" 2776 + }, 2777 + "updated_at": { 2778 + "name": "updated_at", 2779 + "type": "timestamp with time zone", 2780 + "primaryKey": false, 2781 + "notNull": true, 2782 + "default": "now()" 2783 + } 2784 + }, 2785 + "indexes": { 2786 + "community_filters_status_idx": { 2787 + "name": "community_filters_status_idx", 2788 + "columns": [ 2789 + { 2790 + "expression": "status", 2791 + "isExpression": false, 2792 + "asc": true, 2793 + "nulls": "last" 2794 + } 2795 + ], 2796 + "isUnique": false, 2797 + "concurrently": false, 2798 + "method": "btree", 2799 + "with": {} 2800 + }, 2801 + "community_filters_admin_did_idx": { 2802 + "name": "community_filters_admin_did_idx", 2803 + "columns": [ 2804 + { 2805 + "expression": "admin_did", 2806 + "isExpression": false, 2807 + "asc": true, 2808 + "nulls": "last" 2809 + } 2810 + ], 2811 + "isUnique": false, 2812 + "concurrently": false, 2813 + "method": "btree", 2814 + "with": {} 2815 + }, 2816 + "community_filters_updated_at_idx": { 2817 + "name": "community_filters_updated_at_idx", 2818 + "columns": [ 2819 + { 2820 + "expression": "updated_at", 2821 + "isExpression": false, 2822 + "asc": true, 2823 + "nulls": "last" 2824 + } 2825 + ], 2826 + "isUnique": false, 2827 + "concurrently": false, 2828 + "method": "btree", 2829 + "with": {} 2830 + } 2831 + }, 2832 + "foreignKeys": {}, 2833 + "compositePrimaryKeys": {}, 2834 + "uniqueConstraints": {}, 2835 + "policies": { 2836 + "tenant_isolation": { 2837 + "name": "tenant_isolation", 2838 + "as": "PERMISSIVE", 2839 + "for": "ALL", 2840 + "to": [ 2841 + "barazo_app" 2842 + ], 2843 + "using": "community_did = current_setting('app.current_community_did', true)", 2844 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 2845 + } 2846 + }, 2847 + "checkConstraints": {}, 2848 + "isRLSEnabled": true 2849 + }, 2850 + "public.account_filters": { 2851 + "name": "account_filters", 2852 + "schema": "", 2853 + "columns": { 2854 + "id": { 2855 + "name": "id", 2856 + "type": "serial", 2857 + "primaryKey": true, 2858 + "notNull": true 2859 + }, 2860 + "did": { 2861 + "name": "did", 2862 + "type": "text", 2863 + "primaryKey": false, 2864 + "notNull": true 2865 + }, 2866 + "community_did": { 2867 + "name": "community_did", 2868 + "type": "text", 2869 + "primaryKey": false, 2870 + "notNull": true 2871 + }, 2872 + "status": { 2873 + "name": "status", 2874 + "type": "text", 2875 + "primaryKey": false, 2876 + "notNull": true, 2877 + "default": "'active'" 2878 + }, 2879 + "reason": { 2880 + "name": "reason", 2881 + "type": "text", 2882 + "primaryKey": false, 2883 + "notNull": false 2884 + }, 2885 + "report_count": { 2886 + "name": "report_count", 2887 + "type": "integer", 2888 + "primaryKey": false, 2889 + "notNull": true, 2890 + "default": 0 2891 + }, 2892 + "ban_count": { 2893 + "name": "ban_count", 2894 + "type": "integer", 2895 + "primaryKey": false, 2896 + "notNull": true, 2897 + "default": 0 2898 + }, 2899 + "last_reviewed_at": { 2900 + "name": "last_reviewed_at", 2901 + "type": "timestamp with time zone", 2902 + "primaryKey": false, 2903 + "notNull": false 2904 + }, 2905 + "filtered_by": { 2906 + "name": "filtered_by", 2907 + "type": "text", 2908 + "primaryKey": false, 2909 + "notNull": false 2910 + }, 2911 + "created_at": { 2912 + "name": "created_at", 2913 + "type": "timestamp with time zone", 2914 + "primaryKey": false, 2915 + "notNull": true, 2916 + "default": "now()" 2917 + }, 2918 + "updated_at": { 2919 + "name": "updated_at", 2920 + "type": "timestamp with time zone", 2921 + "primaryKey": false, 2922 + "notNull": true, 2923 + "default": "now()" 2924 + } 2925 + }, 2926 + "indexes": { 2927 + "account_filters_did_community_idx": { 2928 + "name": "account_filters_did_community_idx", 2929 + "columns": [ 2930 + { 2931 + "expression": "did", 2932 + "isExpression": false, 2933 + "asc": true, 2934 + "nulls": "last" 2935 + }, 2936 + { 2937 + "expression": "community_did", 2938 + "isExpression": false, 2939 + "asc": true, 2940 + "nulls": "last" 2941 + } 2942 + ], 2943 + "isUnique": true, 2944 + "concurrently": false, 2945 + "method": "btree", 2946 + "with": {} 2947 + }, 2948 + "account_filters_did_idx": { 2949 + "name": "account_filters_did_idx", 2950 + "columns": [ 2951 + { 2952 + "expression": "did", 2953 + "isExpression": false, 2954 + "asc": true, 2955 + "nulls": "last" 2956 + } 2957 + ], 2958 + "isUnique": false, 2959 + "concurrently": false, 2960 + "method": "btree", 2961 + "with": {} 2962 + }, 2963 + "account_filters_community_did_idx": { 2964 + "name": "account_filters_community_did_idx", 2965 + "columns": [ 2966 + { 2967 + "expression": "community_did", 2968 + "isExpression": false, 2969 + "asc": true, 2970 + "nulls": "last" 2971 + } 2972 + ], 2973 + "isUnique": false, 2974 + "concurrently": false, 2975 + "method": "btree", 2976 + "with": {} 2977 + }, 2978 + "account_filters_status_idx": { 2979 + "name": "account_filters_status_idx", 2980 + "columns": [ 2981 + { 2982 + "expression": "status", 2983 + "isExpression": false, 2984 + "asc": true, 2985 + "nulls": "last" 2986 + } 2987 + ], 2988 + "isUnique": false, 2989 + "concurrently": false, 2990 + "method": "btree", 2991 + "with": {} 2992 + }, 2993 + "account_filters_updated_at_idx": { 2994 + "name": "account_filters_updated_at_idx", 2995 + "columns": [ 2996 + { 2997 + "expression": "updated_at", 2998 + "isExpression": false, 2999 + "asc": true, 3000 + "nulls": "last" 3001 + } 3002 + ], 3003 + "isUnique": false, 3004 + "concurrently": false, 3005 + "method": "btree", 3006 + "with": {} 3007 + } 3008 + }, 3009 + "foreignKeys": {}, 3010 + "compositePrimaryKeys": {}, 3011 + "uniqueConstraints": {}, 3012 + "policies": { 3013 + "tenant_isolation": { 3014 + "name": "tenant_isolation", 3015 + "as": "PERMISSIVE", 3016 + "for": "ALL", 3017 + "to": [ 3018 + "barazo_app" 3019 + ], 3020 + "using": "community_did = current_setting('app.current_community_did', true)", 3021 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 3022 + } 3023 + }, 3024 + "checkConstraints": {}, 3025 + "isRLSEnabled": true 3026 + }, 3027 + "public.ozone_labels": { 3028 + "name": "ozone_labels", 3029 + "schema": "", 3030 + "columns": { 3031 + "id": { 3032 + "name": "id", 3033 + "type": "serial", 3034 + "primaryKey": true, 3035 + "notNull": true 3036 + }, 3037 + "src": { 3038 + "name": "src", 3039 + "type": "text", 3040 + "primaryKey": false, 3041 + "notNull": true 3042 + }, 3043 + "uri": { 3044 + "name": "uri", 3045 + "type": "text", 3046 + "primaryKey": false, 3047 + "notNull": true 3048 + }, 3049 + "val": { 3050 + "name": "val", 3051 + "type": "text", 3052 + "primaryKey": false, 3053 + "notNull": true 3054 + }, 3055 + "neg": { 3056 + "name": "neg", 3057 + "type": "boolean", 3058 + "primaryKey": false, 3059 + "notNull": true, 3060 + "default": false 3061 + }, 3062 + "cts": { 3063 + "name": "cts", 3064 + "type": "timestamp with time zone", 3065 + "primaryKey": false, 3066 + "notNull": true 3067 + }, 3068 + "exp": { 3069 + "name": "exp", 3070 + "type": "timestamp with time zone", 3071 + "primaryKey": false, 3072 + "notNull": false 3073 + }, 3074 + "indexed_at": { 3075 + "name": "indexed_at", 3076 + "type": "timestamp with time zone", 3077 + "primaryKey": false, 3078 + "notNull": true, 3079 + "default": "now()" 3080 + } 3081 + }, 3082 + "indexes": { 3083 + "ozone_labels_src_uri_val_idx": { 3084 + "name": "ozone_labels_src_uri_val_idx", 3085 + "columns": [ 3086 + { 3087 + "expression": "src", 3088 + "isExpression": false, 3089 + "asc": true, 3090 + "nulls": "last" 3091 + }, 3092 + { 3093 + "expression": "uri", 3094 + "isExpression": false, 3095 + "asc": true, 3096 + "nulls": "last" 3097 + }, 3098 + { 3099 + "expression": "val", 3100 + "isExpression": false, 3101 + "asc": true, 3102 + "nulls": "last" 3103 + } 3104 + ], 3105 + "isUnique": true, 3106 + "concurrently": false, 3107 + "method": "btree", 3108 + "with": {} 3109 + }, 3110 + "ozone_labels_uri_idx": { 3111 + "name": "ozone_labels_uri_idx", 3112 + "columns": [ 3113 + { 3114 + "expression": "uri", 3115 + "isExpression": false, 3116 + "asc": true, 3117 + "nulls": "last" 3118 + } 3119 + ], 3120 + "isUnique": false, 3121 + "concurrently": false, 3122 + "method": "btree", 3123 + "with": {} 3124 + }, 3125 + "ozone_labels_val_idx": { 3126 + "name": "ozone_labels_val_idx", 3127 + "columns": [ 3128 + { 3129 + "expression": "val", 3130 + "isExpression": false, 3131 + "asc": true, 3132 + "nulls": "last" 3133 + } 3134 + ], 3135 + "isUnique": false, 3136 + "concurrently": false, 3137 + "method": "btree", 3138 + "with": {} 3139 + }, 3140 + "ozone_labels_indexed_at_idx": { 3141 + "name": "ozone_labels_indexed_at_idx", 3142 + "columns": [ 3143 + { 3144 + "expression": "indexed_at", 3145 + "isExpression": false, 3146 + "asc": true, 3147 + "nulls": "last" 3148 + } 3149 + ], 3150 + "isUnique": false, 3151 + "concurrently": false, 3152 + "method": "btree", 3153 + "with": {} 3154 + } 3155 + }, 3156 + "foreignKeys": {}, 3157 + "compositePrimaryKeys": {}, 3158 + "uniqueConstraints": {}, 3159 + "policies": {}, 3160 + "checkConstraints": {}, 3161 + "isRLSEnabled": false 3162 + }, 3163 + "public.community_profiles": { 3164 + "name": "community_profiles", 3165 + "schema": "", 3166 + "columns": { 3167 + "did": { 3168 + "name": "did", 3169 + "type": "text", 3170 + "primaryKey": false, 3171 + "notNull": true 3172 + }, 3173 + "community_did": { 3174 + "name": "community_did", 3175 + "type": "text", 3176 + "primaryKey": false, 3177 + "notNull": true 3178 + }, 3179 + "display_name": { 3180 + "name": "display_name", 3181 + "type": "text", 3182 + "primaryKey": false, 3183 + "notNull": false 3184 + }, 3185 + "avatar_url": { 3186 + "name": "avatar_url", 3187 + "type": "text", 3188 + "primaryKey": false, 3189 + "notNull": false 3190 + }, 3191 + "banner_url": { 3192 + "name": "banner_url", 3193 + "type": "text", 3194 + "primaryKey": false, 3195 + "notNull": false 3196 + }, 3197 + "bio": { 3198 + "name": "bio", 3199 + "type": "text", 3200 + "primaryKey": false, 3201 + "notNull": false 3202 + }, 3203 + "updated_at": { 3204 + "name": "updated_at", 3205 + "type": "timestamp with time zone", 3206 + "primaryKey": false, 3207 + "notNull": true, 3208 + "default": "now()" 3209 + } 3210 + }, 3211 + "indexes": { 3212 + "community_profiles_did_idx": { 3213 + "name": "community_profiles_did_idx", 3214 + "columns": [ 3215 + { 3216 + "expression": "did", 3217 + "isExpression": false, 3218 + "asc": true, 3219 + "nulls": "last" 3220 + } 3221 + ], 3222 + "isUnique": false, 3223 + "concurrently": false, 3224 + "method": "btree", 3225 + "with": {} 3226 + }, 3227 + "community_profiles_community_idx": { 3228 + "name": "community_profiles_community_idx", 3229 + "columns": [ 3230 + { 3231 + "expression": "community_did", 3232 + "isExpression": false, 3233 + "asc": true, 3234 + "nulls": "last" 3235 + } 3236 + ], 3237 + "isUnique": false, 3238 + "concurrently": false, 3239 + "method": "btree", 3240 + "with": {} 3241 + } 3242 + }, 3243 + "foreignKeys": {}, 3244 + "compositePrimaryKeys": { 3245 + "community_profiles_did_community_did_pk": { 3246 + "name": "community_profiles_did_community_did_pk", 3247 + "columns": [ 3248 + "did", 3249 + "community_did" 3250 + ] 3251 + } 3252 + }, 3253 + "uniqueConstraints": {}, 3254 + "policies": { 3255 + "tenant_isolation": { 3256 + "name": "tenant_isolation", 3257 + "as": "PERMISSIVE", 3258 + "for": "ALL", 3259 + "to": [ 3260 + "barazo_app" 3261 + ], 3262 + "using": "community_did = current_setting('app.current_community_did', true)", 3263 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 3264 + } 3265 + }, 3266 + "checkConstraints": {}, 3267 + "isRLSEnabled": true 3268 + }, 3269 + "public.interaction_graph": { 3270 + "name": "interaction_graph", 3271 + "schema": "", 3272 + "columns": { 3273 + "source_did": { 3274 + "name": "source_did", 3275 + "type": "text", 3276 + "primaryKey": false, 3277 + "notNull": true 3278 + }, 3279 + "target_did": { 3280 + "name": "target_did", 3281 + "type": "text", 3282 + "primaryKey": false, 3283 + "notNull": true 3284 + }, 3285 + "community_id": { 3286 + "name": "community_id", 3287 + "type": "text", 3288 + "primaryKey": false, 3289 + "notNull": true 3290 + }, 3291 + "interaction_type": { 3292 + "name": "interaction_type", 3293 + "type": "text", 3294 + "primaryKey": false, 3295 + "notNull": true 3296 + }, 3297 + "weight": { 3298 + "name": "weight", 3299 + "type": "integer", 3300 + "primaryKey": false, 3301 + "notNull": true, 3302 + "default": 1 3303 + }, 3304 + "first_interaction_at": { 3305 + "name": "first_interaction_at", 3306 + "type": "timestamp with time zone", 3307 + "primaryKey": false, 3308 + "notNull": true, 3309 + "default": "now()" 3310 + }, 3311 + "last_interaction_at": { 3312 + "name": "last_interaction_at", 3313 + "type": "timestamp with time zone", 3314 + "primaryKey": false, 3315 + "notNull": true, 3316 + "default": "now()" 3317 + } 3318 + }, 3319 + "indexes": { 3320 + "interaction_graph_source_target_community_idx": { 3321 + "name": "interaction_graph_source_target_community_idx", 3322 + "columns": [ 3323 + { 3324 + "expression": "source_did", 3325 + "isExpression": false, 3326 + "asc": true, 3327 + "nulls": "last" 3328 + }, 3329 + { 3330 + "expression": "target_did", 3331 + "isExpression": false, 3332 + "asc": true, 3333 + "nulls": "last" 3334 + }, 3335 + { 3336 + "expression": "community_id", 3337 + "isExpression": false, 3338 + "asc": true, 3339 + "nulls": "last" 3340 + } 3341 + ], 3342 + "isUnique": false, 3343 + "concurrently": false, 3344 + "method": "btree", 3345 + "with": {} 3346 + } 3347 + }, 3348 + "foreignKeys": {}, 3349 + "compositePrimaryKeys": { 3350 + "interaction_graph_source_did_target_did_community_id_interaction_type_pk": { 3351 + "name": "interaction_graph_source_did_target_did_community_id_interaction_type_pk", 3352 + "columns": [ 3353 + "source_did", 3354 + "target_did", 3355 + "community_id", 3356 + "interaction_type" 3357 + ] 3358 + } 3359 + }, 3360 + "uniqueConstraints": {}, 3361 + "policies": {}, 3362 + "checkConstraints": {}, 3363 + "isRLSEnabled": false 3364 + }, 3365 + "public.trust_seeds": { 3366 + "name": "trust_seeds", 3367 + "schema": "", 3368 + "columns": { 3369 + "id": { 3370 + "name": "id", 3371 + "type": "serial", 3372 + "primaryKey": true, 3373 + "notNull": true 3374 + }, 3375 + "did": { 3376 + "name": "did", 3377 + "type": "text", 3378 + "primaryKey": false, 3379 + "notNull": true 3380 + }, 3381 + "community_id": { 3382 + "name": "community_id", 3383 + "type": "text", 3384 + "primaryKey": false, 3385 + "notNull": true, 3386 + "default": "''" 3387 + }, 3388 + "added_by": { 3389 + "name": "added_by", 3390 + "type": "text", 3391 + "primaryKey": false, 3392 + "notNull": true 3393 + }, 3394 + "reason": { 3395 + "name": "reason", 3396 + "type": "text", 3397 + "primaryKey": false, 3398 + "notNull": false 3399 + }, 3400 + "created_at": { 3401 + "name": "created_at", 3402 + "type": "timestamp with time zone", 3403 + "primaryKey": false, 3404 + "notNull": true, 3405 + "default": "now()" 3406 + } 3407 + }, 3408 + "indexes": { 3409 + "trust_seeds_did_community_idx": { 3410 + "name": "trust_seeds_did_community_idx", 3411 + "columns": [ 3412 + { 3413 + "expression": "did", 3414 + "isExpression": false, 3415 + "asc": true, 3416 + "nulls": "last" 3417 + }, 3418 + { 3419 + "expression": "community_id", 3420 + "isExpression": false, 3421 + "asc": true, 3422 + "nulls": "last" 3423 + } 3424 + ], 3425 + "isUnique": true, 3426 + "concurrently": false, 3427 + "method": "btree", 3428 + "with": {} 3429 + } 3430 + }, 3431 + "foreignKeys": {}, 3432 + "compositePrimaryKeys": {}, 3433 + "uniqueConstraints": {}, 3434 + "policies": {}, 3435 + "checkConstraints": {}, 3436 + "isRLSEnabled": false 3437 + }, 3438 + "public.trust_scores": { 3439 + "name": "trust_scores", 3440 + "schema": "", 3441 + "columns": { 3442 + "did": { 3443 + "name": "did", 3444 + "type": "text", 3445 + "primaryKey": false, 3446 + "notNull": true 3447 + }, 3448 + "community_id": { 3449 + "name": "community_id", 3450 + "type": "text", 3451 + "primaryKey": false, 3452 + "notNull": true, 3453 + "default": "''" 3454 + }, 3455 + "score": { 3456 + "name": "score", 3457 + "type": "real", 3458 + "primaryKey": false, 3459 + "notNull": true 3460 + }, 3461 + "computed_at": { 3462 + "name": "computed_at", 3463 + "type": "timestamp with time zone", 3464 + "primaryKey": false, 3465 + "notNull": true, 3466 + "default": "now()" 3467 + } 3468 + }, 3469 + "indexes": { 3470 + "trust_scores_did_community_idx": { 3471 + "name": "trust_scores_did_community_idx", 3472 + "columns": [ 3473 + { 3474 + "expression": "did", 3475 + "isExpression": false, 3476 + "asc": true, 3477 + "nulls": "last" 3478 + }, 3479 + { 3480 + "expression": "community_id", 3481 + "isExpression": false, 3482 + "asc": true, 3483 + "nulls": "last" 3484 + } 3485 + ], 3486 + "isUnique": false, 3487 + "concurrently": false, 3488 + "method": "btree", 3489 + "with": {} 3490 + } 3491 + }, 3492 + "foreignKeys": {}, 3493 + "compositePrimaryKeys": { 3494 + "trust_scores_did_community_id_pk": { 3495 + "name": "trust_scores_did_community_id_pk", 3496 + "columns": [ 3497 + "did", 3498 + "community_id" 3499 + ] 3500 + } 3501 + }, 3502 + "uniqueConstraints": {}, 3503 + "policies": {}, 3504 + "checkConstraints": {}, 3505 + "isRLSEnabled": false 3506 + }, 3507 + "public.sybil_clusters": { 3508 + "name": "sybil_clusters", 3509 + "schema": "", 3510 + "columns": { 3511 + "id": { 3512 + "name": "id", 3513 + "type": "serial", 3514 + "primaryKey": true, 3515 + "notNull": true 3516 + }, 3517 + "cluster_hash": { 3518 + "name": "cluster_hash", 3519 + "type": "text", 3520 + "primaryKey": false, 3521 + "notNull": true 3522 + }, 3523 + "internal_edge_count": { 3524 + "name": "internal_edge_count", 3525 + "type": "integer", 3526 + "primaryKey": false, 3527 + "notNull": true 3528 + }, 3529 + "external_edge_count": { 3530 + "name": "external_edge_count", 3531 + "type": "integer", 3532 + "primaryKey": false, 3533 + "notNull": true 3534 + }, 3535 + "member_count": { 3536 + "name": "member_count", 3537 + "type": "integer", 3538 + "primaryKey": false, 3539 + "notNull": true 3540 + }, 3541 + "status": { 3542 + "name": "status", 3543 + "type": "text", 3544 + "primaryKey": false, 3545 + "notNull": true, 3546 + "default": "'flagged'" 3547 + }, 3548 + "reviewed_by": { 3549 + "name": "reviewed_by", 3550 + "type": "text", 3551 + "primaryKey": false, 3552 + "notNull": false 3553 + }, 3554 + "reviewed_at": { 3555 + "name": "reviewed_at", 3556 + "type": "timestamp with time zone", 3557 + "primaryKey": false, 3558 + "notNull": false 3559 + }, 3560 + "detected_at": { 3561 + "name": "detected_at", 3562 + "type": "timestamp with time zone", 3563 + "primaryKey": false, 3564 + "notNull": true, 3565 + "default": "now()" 3566 + }, 3567 + "updated_at": { 3568 + "name": "updated_at", 3569 + "type": "timestamp with time zone", 3570 + "primaryKey": false, 3571 + "notNull": true, 3572 + "default": "now()" 3573 + } 3574 + }, 3575 + "indexes": { 3576 + "sybil_clusters_hash_idx": { 3577 + "name": "sybil_clusters_hash_idx", 3578 + "columns": [ 3579 + { 3580 + "expression": "cluster_hash", 3581 + "isExpression": false, 3582 + "asc": true, 3583 + "nulls": "last" 3584 + } 3585 + ], 3586 + "isUnique": true, 3587 + "concurrently": false, 3588 + "method": "btree", 3589 + "with": {} 3590 + } 3591 + }, 3592 + "foreignKeys": {}, 3593 + "compositePrimaryKeys": {}, 3594 + "uniqueConstraints": {}, 3595 + "policies": {}, 3596 + "checkConstraints": {}, 3597 + "isRLSEnabled": false 3598 + }, 3599 + "public.sybil_cluster_members": { 3600 + "name": "sybil_cluster_members", 3601 + "schema": "", 3602 + "columns": { 3603 + "cluster_id": { 3604 + "name": "cluster_id", 3605 + "type": "integer", 3606 + "primaryKey": false, 3607 + "notNull": true 3608 + }, 3609 + "did": { 3610 + "name": "did", 3611 + "type": "text", 3612 + "primaryKey": false, 3613 + "notNull": true 3614 + }, 3615 + "role_in_cluster": { 3616 + "name": "role_in_cluster", 3617 + "type": "text", 3618 + "primaryKey": false, 3619 + "notNull": true 3620 + }, 3621 + "joined_at": { 3622 + "name": "joined_at", 3623 + "type": "timestamp with time zone", 3624 + "primaryKey": false, 3625 + "notNull": true, 3626 + "default": "now()" 3627 + } 3628 + }, 3629 + "indexes": {}, 3630 + "foreignKeys": { 3631 + "sybil_cluster_members_cluster_id_sybil_clusters_id_fk": { 3632 + "name": "sybil_cluster_members_cluster_id_sybil_clusters_id_fk", 3633 + "tableFrom": "sybil_cluster_members", 3634 + "tableTo": "sybil_clusters", 3635 + "columnsFrom": [ 3636 + "cluster_id" 3637 + ], 3638 + "columnsTo": [ 3639 + "id" 3640 + ], 3641 + "onDelete": "no action", 3642 + "onUpdate": "no action" 3643 + } 3644 + }, 3645 + "compositePrimaryKeys": { 3646 + "sybil_cluster_members_cluster_id_did_pk": { 3647 + "name": "sybil_cluster_members_cluster_id_did_pk", 3648 + "columns": [ 3649 + "cluster_id", 3650 + "did" 3651 + ] 3652 + } 3653 + }, 3654 + "uniqueConstraints": {}, 3655 + "policies": {}, 3656 + "checkConstraints": {}, 3657 + "isRLSEnabled": false 3658 + }, 3659 + "public.behavioral_flags": { 3660 + "name": "behavioral_flags", 3661 + "schema": "", 3662 + "columns": { 3663 + "id": { 3664 + "name": "id", 3665 + "type": "serial", 3666 + "primaryKey": true, 3667 + "notNull": true 3668 + }, 3669 + "flag_type": { 3670 + "name": "flag_type", 3671 + "type": "text", 3672 + "primaryKey": false, 3673 + "notNull": true 3674 + }, 3675 + "affected_dids": { 3676 + "name": "affected_dids", 3677 + "type": "jsonb", 3678 + "primaryKey": false, 3679 + "notNull": true 3680 + }, 3681 + "details": { 3682 + "name": "details", 3683 + "type": "text", 3684 + "primaryKey": false, 3685 + "notNull": true 3686 + }, 3687 + "community_did": { 3688 + "name": "community_did", 3689 + "type": "text", 3690 + "primaryKey": false, 3691 + "notNull": false 3692 + }, 3693 + "status": { 3694 + "name": "status", 3695 + "type": "text", 3696 + "primaryKey": false, 3697 + "notNull": true, 3698 + "default": "'pending'" 3699 + }, 3700 + "detected_at": { 3701 + "name": "detected_at", 3702 + "type": "timestamp with time zone", 3703 + "primaryKey": false, 3704 + "notNull": true, 3705 + "default": "now()" 3706 + } 3707 + }, 3708 + "indexes": { 3709 + "behavioral_flags_flag_type_idx": { 3710 + "name": "behavioral_flags_flag_type_idx", 3711 + "columns": [ 3712 + { 3713 + "expression": "flag_type", 3714 + "isExpression": false, 3715 + "asc": true, 3716 + "nulls": "last" 3717 + } 3718 + ], 3719 + "isUnique": false, 3720 + "concurrently": false, 3721 + "method": "btree", 3722 + "with": {} 3723 + }, 3724 + "behavioral_flags_status_idx": { 3725 + "name": "behavioral_flags_status_idx", 3726 + "columns": [ 3727 + { 3728 + "expression": "status", 3729 + "isExpression": false, 3730 + "asc": true, 3731 + "nulls": "last" 3732 + } 3733 + ], 3734 + "isUnique": false, 3735 + "concurrently": false, 3736 + "method": "btree", 3737 + "with": {} 3738 + }, 3739 + "behavioral_flags_detected_at_idx": { 3740 + "name": "behavioral_flags_detected_at_idx", 3741 + "columns": [ 3742 + { 3743 + "expression": "detected_at", 3744 + "isExpression": false, 3745 + "asc": true, 3746 + "nulls": "last" 3747 + } 3748 + ], 3749 + "isUnique": false, 3750 + "concurrently": false, 3751 + "method": "btree", 3752 + "with": {} 3753 + } 3754 + }, 3755 + "foreignKeys": {}, 3756 + "compositePrimaryKeys": {}, 3757 + "uniqueConstraints": {}, 3758 + "policies": {}, 3759 + "checkConstraints": {}, 3760 + "isRLSEnabled": false 3761 + }, 3762 + "public.pds_trust_factors": { 3763 + "name": "pds_trust_factors", 3764 + "schema": "", 3765 + "columns": { 3766 + "id": { 3767 + "name": "id", 3768 + "type": "serial", 3769 + "primaryKey": true, 3770 + "notNull": true 3771 + }, 3772 + "pds_host": { 3773 + "name": "pds_host", 3774 + "type": "text", 3775 + "primaryKey": false, 3776 + "notNull": true 3777 + }, 3778 + "trust_factor": { 3779 + "name": "trust_factor", 3780 + "type": "real", 3781 + "primaryKey": false, 3782 + "notNull": true 3783 + }, 3784 + "is_default": { 3785 + "name": "is_default", 3786 + "type": "boolean", 3787 + "primaryKey": false, 3788 + "notNull": true, 3789 + "default": false 3790 + }, 3791 + "updated_at": { 3792 + "name": "updated_at", 3793 + "type": "timestamp with time zone", 3794 + "primaryKey": false, 3795 + "notNull": true, 3796 + "default": "now()" 3797 + } 3798 + }, 3799 + "indexes": { 3800 + "pds_trust_factors_pds_host_idx": { 3801 + "name": "pds_trust_factors_pds_host_idx", 3802 + "columns": [ 3803 + { 3804 + "expression": "pds_host", 3805 + "isExpression": false, 3806 + "asc": true, 3807 + "nulls": "last" 3808 + } 3809 + ], 3810 + "isUnique": true, 3811 + "concurrently": false, 3812 + "method": "btree", 3813 + "with": {} 3814 + } 3815 + }, 3816 + "foreignKeys": {}, 3817 + "compositePrimaryKeys": {}, 3818 + "uniqueConstraints": {}, 3819 + "policies": {}, 3820 + "checkConstraints": {}, 3821 + "isRLSEnabled": false 3822 + }, 3823 + "public.pages": { 3824 + "name": "pages", 3825 + "schema": "", 3826 + "columns": { 3827 + "id": { 3828 + "name": "id", 3829 + "type": "text", 3830 + "primaryKey": true, 3831 + "notNull": true 3832 + }, 3833 + "slug": { 3834 + "name": "slug", 3835 + "type": "text", 3836 + "primaryKey": false, 3837 + "notNull": true 3838 + }, 3839 + "title": { 3840 + "name": "title", 3841 + "type": "text", 3842 + "primaryKey": false, 3843 + "notNull": true 3844 + }, 3845 + "content": { 3846 + "name": "content", 3847 + "type": "text", 3848 + "primaryKey": false, 3849 + "notNull": true 3850 + }, 3851 + "status": { 3852 + "name": "status", 3853 + "type": "text", 3854 + "primaryKey": false, 3855 + "notNull": true, 3856 + "default": "'draft'" 3857 + }, 3858 + "meta_description": { 3859 + "name": "meta_description", 3860 + "type": "text", 3861 + "primaryKey": false, 3862 + "notNull": false 3863 + }, 3864 + "parent_id": { 3865 + "name": "parent_id", 3866 + "type": "text", 3867 + "primaryKey": false, 3868 + "notNull": false 3869 + }, 3870 + "sort_order": { 3871 + "name": "sort_order", 3872 + "type": "integer", 3873 + "primaryKey": false, 3874 + "notNull": true, 3875 + "default": 0 3876 + }, 3877 + "community_did": { 3878 + "name": "community_did", 3879 + "type": "text", 3880 + "primaryKey": false, 3881 + "notNull": true 3882 + }, 3883 + "created_at": { 3884 + "name": "created_at", 3885 + "type": "timestamp with time zone", 3886 + "primaryKey": false, 3887 + "notNull": true, 3888 + "default": "now()" 3889 + }, 3890 + "updated_at": { 3891 + "name": "updated_at", 3892 + "type": "timestamp with time zone", 3893 + "primaryKey": false, 3894 + "notNull": true, 3895 + "default": "now()" 3896 + } 3897 + }, 3898 + "indexes": { 3899 + "pages_slug_community_did_idx": { 3900 + "name": "pages_slug_community_did_idx", 3901 + "columns": [ 3902 + { 3903 + "expression": "slug", 3904 + "isExpression": false, 3905 + "asc": true, 3906 + "nulls": "last" 3907 + }, 3908 + { 3909 + "expression": "community_did", 3910 + "isExpression": false, 3911 + "asc": true, 3912 + "nulls": "last" 3913 + } 3914 + ], 3915 + "isUnique": true, 3916 + "concurrently": false, 3917 + "method": "btree", 3918 + "with": {} 3919 + }, 3920 + "pages_community_did_idx": { 3921 + "name": "pages_community_did_idx", 3922 + "columns": [ 3923 + { 3924 + "expression": "community_did", 3925 + "isExpression": false, 3926 + "asc": true, 3927 + "nulls": "last" 3928 + } 3929 + ], 3930 + "isUnique": false, 3931 + "concurrently": false, 3932 + "method": "btree", 3933 + "with": {} 3934 + }, 3935 + "pages_parent_id_idx": { 3936 + "name": "pages_parent_id_idx", 3937 + "columns": [ 3938 + { 3939 + "expression": "parent_id", 3940 + "isExpression": false, 3941 + "asc": true, 3942 + "nulls": "last" 3943 + } 3944 + ], 3945 + "isUnique": false, 3946 + "concurrently": false, 3947 + "method": "btree", 3948 + "with": {} 3949 + }, 3950 + "pages_status_community_did_idx": { 3951 + "name": "pages_status_community_did_idx", 3952 + "columns": [ 3953 + { 3954 + "expression": "status", 3955 + "isExpression": false, 3956 + "asc": true, 3957 + "nulls": "last" 3958 + }, 3959 + { 3960 + "expression": "community_did", 3961 + "isExpression": false, 3962 + "asc": true, 3963 + "nulls": "last" 3964 + } 3965 + ], 3966 + "isUnique": false, 3967 + "concurrently": false, 3968 + "method": "btree", 3969 + "with": {} 3970 + } 3971 + }, 3972 + "foreignKeys": { 3973 + "pages_parent_id_fk": { 3974 + "name": "pages_parent_id_fk", 3975 + "tableFrom": "pages", 3976 + "tableTo": "pages", 3977 + "columnsFrom": [ 3978 + "parent_id" 3979 + ], 3980 + "columnsTo": [ 3981 + "id" 3982 + ], 3983 + "onDelete": "set null", 3984 + "onUpdate": "no action" 3985 + } 3986 + }, 3987 + "compositePrimaryKeys": {}, 3988 + "uniqueConstraints": {}, 3989 + "policies": { 3990 + "tenant_isolation": { 3991 + "name": "tenant_isolation", 3992 + "as": "PERMISSIVE", 3993 + "for": "ALL", 3994 + "to": [ 3995 + "barazo_app" 3996 + ], 3997 + "using": "community_did = current_setting('app.current_community_did', true)", 3998 + "withCheck": "community_did = current_setting('app.current_community_did', true)" 3999 + } 4000 + }, 4001 + "checkConstraints": {}, 4002 + "isRLSEnabled": true 4003 + } 4004 + }, 4005 + "enums": {}, 4006 + "schemas": {}, 4007 + "sequences": {}, 4008 + "roles": { 4009 + "barazo_app": { 4010 + "name": "barazo_app", 4011 + "createDb": false, 4012 + "createRole": false, 4013 + "inherit": true 4014 + } 4015 + }, 4016 + "policies": {}, 4017 + "views": {}, 4018 + "_meta": { 4019 + "columns": {}, 4020 + "schemas": {}, 4021 + "tables": {} 4022 + } 4023 + }
+8 -1
drizzle/meta/_journal.json
··· 29 29 "when": 1772556676000, 30 30 "tag": "0003_backfill-platform-age-field", 31 31 "breakpoints": true 32 + }, 33 + { 34 + "idx": 4, 35 + "version": "7", 36 + "when": 1772616692948, 37 + "tag": "0004_add_pages_table", 38 + "breakpoints": true 32 39 } 33 40 ] 34 - } 41 + }
+2
src/app.ts
··· 29 29 import { topicRoutes } from './routes/topics.js' 30 30 import { replyRoutes } from './routes/replies.js' 31 31 import { categoryRoutes } from './routes/categories.js' 32 + import { pageRoutes } from './routes/pages.js' 32 33 import { adminSettingsRoutes } from './routes/admin-settings.js' 33 34 import { reactionRoutes } from './routes/reactions.js' 34 35 import { voteRoutes } from './routes/votes.js' ··· 323 324 await app.register(topicRoutes()) 324 325 await app.register(replyRoutes()) 325 326 await app.register(categoryRoutes()) 327 + await app.register(pageRoutes()) 326 328 await app.register(adminSettingsRoutes()) 327 329 await app.register(reactionRoutes()) 328 330 await app.register(voteRoutes())
+1
src/db/schema/index.ts
··· 27 27 export { sybilClusterMembers } from './sybil-cluster-members.js' 28 28 export { behavioralFlags } from './behavioral-flags.js' 29 29 export { pdsTrustFactors } from './pds-trust-factors.js' 30 + export { pages } from './pages.js'
+51
src/db/schema/pages.ts
··· 1 + import { 2 + pgTable, 3 + pgPolicy, 4 + text, 5 + integer, 6 + timestamp, 7 + index, 8 + uniqueIndex, 9 + foreignKey, 10 + } from 'drizzle-orm/pg-core' 11 + import { sql } from 'drizzle-orm' 12 + import { appRole } from './roles.js' 13 + 14 + export const pages = pgTable( 15 + 'pages', 16 + { 17 + id: text('id').primaryKey(), 18 + slug: text('slug').notNull(), 19 + title: text('title').notNull(), 20 + content: text('content').notNull(), 21 + status: text('status', { 22 + enum: ['draft', 'published'], 23 + }) 24 + .notNull() 25 + .default('draft'), 26 + metaDescription: text('meta_description'), 27 + parentId: text('parent_id'), 28 + sortOrder: integer('sort_order').notNull().default(0), 29 + communityDid: text('community_did').notNull(), 30 + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), 31 + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), 32 + }, 33 + (table) => [ 34 + uniqueIndex('pages_slug_community_did_idx').on(table.slug, table.communityDid), 35 + index('pages_community_did_idx').on(table.communityDid), 36 + index('pages_parent_id_idx').on(table.parentId), 37 + index('pages_status_community_did_idx').on(table.status, table.communityDid), 38 + foreignKey({ 39 + columns: [table.parentId], 40 + foreignColumns: [table.id], 41 + name: 'pages_parent_id_fk', 42 + }).onDelete('set null'), 43 + pgPolicy('tenant_isolation', { 44 + as: 'permissive', 45 + to: appRole, 46 + for: 'all', 47 + using: sql`community_did = current_setting('app.current_community_did', true)`, 48 + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, 49 + }), 50 + ] 51 + ).enableRLS()
+638
src/routes/pages.ts
··· 1 + import { randomUUID } from 'node:crypto' 2 + import { requireCommunityDid } from '../middleware/community-resolver.js' 3 + import { eq, and } from 'drizzle-orm' 4 + import type { FastifyPluginCallback } from 'fastify' 5 + import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' 6 + import { createPageSchema, updatePageSchema } from '../validation/pages.js' 7 + import { pages } from '../db/schema/pages.js' 8 + 9 + /** 10 + * Serialize a page row from the DB into a JSON-safe response object. 11 + * Converts Date fields to ISO strings. 12 + */ 13 + function serializePage(row: typeof pages.$inferSelect) { 14 + return { 15 + id: row.id, 16 + slug: row.slug, 17 + title: row.title, 18 + content: row.content, 19 + status: row.status, 20 + metaDescription: row.metaDescription ?? null, 21 + parentId: row.parentId ?? null, 22 + sortOrder: row.sortOrder, 23 + communityDid: row.communityDid, 24 + createdAt: row.createdAt.toISOString(), 25 + updatedAt: row.updatedAt.toISOString(), 26 + } 27 + } 28 + 29 + interface PageTreeNode { 30 + id: string 31 + slug: string 32 + title: string 33 + content: string 34 + status: string 35 + metaDescription: string | null 36 + parentId: string | null 37 + sortOrder: number 38 + communityDid: string 39 + createdAt: string 40 + updatedAt: string 41 + children: PageTreeNode[] 42 + } 43 + 44 + /** 45 + * Build a tree structure from a flat list of pages. 46 + * Returns only root-level pages (parentId === null), with children nested. 47 + * Sorts children by sortOrder. 48 + */ 49 + function buildPageTree(rows: Array<typeof pages.$inferSelect>): PageTreeNode[] { 50 + const serialized = rows.map((row) => ({ 51 + ...serializePage(row), 52 + children: [] as PageTreeNode[], 53 + })) 54 + 55 + const byId = new Map<string, PageTreeNode>() 56 + for (const node of serialized) { 57 + byId.set(node.id, node) 58 + } 59 + 60 + const roots: PageTreeNode[] = [] 61 + for (const node of serialized) { 62 + if (node.parentId !== null) { 63 + const parent = byId.get(node.parentId) 64 + if (parent) { 65 + parent.children.push(node) 66 + } else { 67 + // Orphan -- treat as root 68 + roots.push(node) 69 + } 70 + } else { 71 + roots.push(node) 72 + } 73 + } 74 + 75 + // Sort children by sortOrder 76 + const sortChildren = (nodes: PageTreeNode[]) => { 77 + nodes.sort((a, b) => a.sortOrder - b.sortOrder) 78 + for (const node of nodes) { 79 + sortChildren(node.children) 80 + } 81 + } 82 + sortChildren(roots) 83 + roots.sort((a, b) => a.sortOrder - b.sortOrder) 84 + 85 + return roots 86 + } 87 + 88 + /** 89 + * Detect circular reference: check if setting `pageId`'s parent to 90 + * `newParentId` would create a cycle. Walks up the ancestor chain of 91 + * `newParentId` to see if it reaches `pageId`. 92 + */ 93 + function wouldCreateCycle( 94 + pageId: string, 95 + newParentId: string, 96 + allPages: Array<{ id: string; parentId: string | null }> 97 + ): boolean { 98 + // Self-reference 99 + if (pageId === newParentId) { 100 + return true 101 + } 102 + 103 + const byId = new Map<string, { id: string; parentId: string | null }>() 104 + for (const page of allPages) { 105 + byId.set(page.id, page) 106 + } 107 + 108 + // Walk up from newParentId 109 + let current = newParentId 110 + const visited = new Set<string>() 111 + while (current) { 112 + if (current === pageId) { 113 + return true 114 + } 115 + if (visited.has(current)) { 116 + // Already in a cycle (should not happen but protect against it) 117 + return true 118 + } 119 + visited.add(current) 120 + const node = byId.get(current) 121 + if (!node?.parentId) { 122 + break 123 + } 124 + current = node.parentId 125 + } 126 + 127 + return false 128 + } 129 + 130 + /** 131 + * Generate a random ID for a new page. 132 + */ 133 + function generateId(): string { 134 + return `page-${randomUUID()}` 135 + } 136 + 137 + // --------------------------------------------------------------------------- 138 + // OpenAPI JSON Schema definitions 139 + // --------------------------------------------------------------------------- 140 + 141 + const pageJsonSchema = { 142 + type: 'object' as const, 143 + properties: { 144 + id: { type: 'string' as const }, 145 + slug: { type: 'string' as const }, 146 + title: { type: 'string' as const }, 147 + content: { type: 'string' as const }, 148 + status: { type: 'string' as const, enum: ['draft', 'published'] }, 149 + metaDescription: { type: ['string', 'null'] as const }, 150 + parentId: { type: ['string', 'null'] as const }, 151 + sortOrder: { type: 'integer' as const }, 152 + communityDid: { type: 'string' as const }, 153 + createdAt: { type: 'string' as const, format: 'date-time' as const }, 154 + updatedAt: { type: 'string' as const, format: 'date-time' as const }, 155 + }, 156 + } 157 + 158 + // --------------------------------------------------------------------------- 159 + // Page routes plugin 160 + // --------------------------------------------------------------------------- 161 + 162 + /** 163 + * Page routes for the Barazo forum (Admin Pages / Mini-CMS). 164 + * 165 + * Public: 166 + * - GET /api/pages -- List published pages (tree structure, content snippets) 167 + * - GET /api/pages/:slug -- Get a single published page (full content) 168 + * 169 + * Admin: 170 + * - GET /api/admin/pages -- List ALL pages (tree structure, including drafts) 171 + * - GET /api/admin/pages/:id -- Get a single page by ID (full content) 172 + * - POST /api/admin/pages -- Create a page 173 + * - PUT /api/admin/pages/:id -- Update a page 174 + * - DELETE /api/admin/pages/:id -- Delete a page 175 + */ 176 + export function pageRoutes(): FastifyPluginCallback { 177 + return (app, _opts, done) => { 178 + const { db, authMiddleware, requireAdmin } = app 179 + 180 + // ------------------------------------------------------------------- 181 + // GET /api/pages (public, optionalAuth) 182 + // ------------------------------------------------------------------- 183 + 184 + app.get( 185 + '/api/pages', 186 + { 187 + preHandler: [authMiddleware.optionalAuth], 188 + schema: { 189 + tags: ['Pages'], 190 + summary: 'List published pages as a tree structure', 191 + response: { 192 + 200: { 193 + type: 'object', 194 + additionalProperties: true, 195 + properties: { 196 + pages: { type: 'array' }, 197 + }, 198 + }, 199 + }, 200 + }, 201 + }, 202 + async (request, reply) => { 203 + const communityDid = requireCommunityDid(request) 204 + 205 + const rows = await db 206 + .select() 207 + .from(pages) 208 + .where(and(eq(pages.status, 'published'), eq(pages.communityDid, communityDid))) 209 + 210 + const tree = buildPageTree(rows) 211 + 212 + // Replace full content with snippets (first 200 chars) 213 + const truncateContent = (nodes: PageTreeNode[]): PageTreeNode[] => { 214 + return nodes.map((node) => ({ 215 + ...node, 216 + content: node.content.length > 200 ? node.content.slice(0, 200) + '...' : node.content, 217 + children: truncateContent(node.children), 218 + })) 219 + } 220 + 221 + return reply.status(200).send({ pages: truncateContent(tree) }) 222 + } 223 + ) 224 + 225 + // ------------------------------------------------------------------- 226 + // GET /api/pages/:slug (public, optionalAuth) 227 + // ------------------------------------------------------------------- 228 + 229 + app.get( 230 + '/api/pages/:slug', 231 + { 232 + preHandler: [authMiddleware.optionalAuth], 233 + schema: { 234 + tags: ['Pages'], 235 + summary: 'Get a single published page by slug', 236 + params: { 237 + type: 'object', 238 + required: ['slug'], 239 + properties: { 240 + slug: { type: 'string' }, 241 + }, 242 + }, 243 + response: { 244 + 200: pageJsonSchema, 245 + 404: errorResponseSchema, 246 + }, 247 + }, 248 + }, 249 + async (request, reply) => { 250 + const { slug } = request.params as { slug: string } 251 + const communityDid = requireCommunityDid(request) 252 + 253 + const rows = await db 254 + .select() 255 + .from(pages) 256 + .where( 257 + and( 258 + eq(pages.slug, slug), 259 + eq(pages.communityDid, communityDid), 260 + eq(pages.status, 'published') 261 + ) 262 + ) 263 + 264 + const row = rows[0] 265 + if (!row) { 266 + throw notFound('Page not found') 267 + } 268 + 269 + return reply.status(200).send(serializePage(row)) 270 + } 271 + ) 272 + 273 + // ------------------------------------------------------------------- 274 + // GET /api/admin/pages (admin required) 275 + // ------------------------------------------------------------------- 276 + 277 + app.get( 278 + '/api/admin/pages', 279 + { 280 + preHandler: [requireAdmin], 281 + schema: { 282 + tags: ['Pages (Admin)'], 283 + summary: 'List all pages as a tree structure (including drafts)', 284 + security: [{ bearerAuth: [] }], 285 + response: { 286 + 200: { 287 + type: 'object', 288 + additionalProperties: true, 289 + properties: { 290 + pages: { type: 'array' }, 291 + }, 292 + }, 293 + 401: errorResponseSchema, 294 + 403: errorResponseSchema, 295 + }, 296 + }, 297 + }, 298 + async (request, reply) => { 299 + const communityDid = requireCommunityDid(request) 300 + 301 + const rows = await db 302 + .select() 303 + .from(pages) 304 + .where(eq(pages.communityDid, communityDid)) 305 + 306 + const tree = buildPageTree(rows) 307 + 308 + return reply.status(200).send({ pages: tree }) 309 + } 310 + ) 311 + 312 + // ------------------------------------------------------------------- 313 + // GET /api/admin/pages/:id (admin required) 314 + // ------------------------------------------------------------------- 315 + 316 + app.get( 317 + '/api/admin/pages/:id', 318 + { 319 + preHandler: [requireAdmin], 320 + schema: { 321 + tags: ['Pages (Admin)'], 322 + summary: 'Get a single page by ID (including drafts)', 323 + security: [{ bearerAuth: [] }], 324 + params: { 325 + type: 'object', 326 + required: ['id'], 327 + properties: { 328 + id: { type: 'string' }, 329 + }, 330 + }, 331 + response: { 332 + 200: pageJsonSchema, 333 + 401: errorResponseSchema, 334 + 403: errorResponseSchema, 335 + 404: errorResponseSchema, 336 + }, 337 + }, 338 + }, 339 + async (request, reply) => { 340 + const { id } = request.params as { id: string } 341 + 342 + const rows = await db.select().from(pages).where(eq(pages.id, id)) 343 + 344 + const row = rows[0] 345 + if (!row) { 346 + throw notFound('Page not found') 347 + } 348 + 349 + return reply.status(200).send(serializePage(row)) 350 + } 351 + ) 352 + 353 + // ------------------------------------------------------------------- 354 + // POST /api/admin/pages (admin required) 355 + // ------------------------------------------------------------------- 356 + 357 + app.post( 358 + '/api/admin/pages', 359 + { 360 + preHandler: [requireAdmin], 361 + schema: { 362 + tags: ['Pages (Admin)'], 363 + summary: 'Create a new page', 364 + security: [{ bearerAuth: [] }], 365 + body: { 366 + type: 'object', 367 + required: ['title', 'slug'], 368 + properties: { 369 + title: { type: 'string', minLength: 1, maxLength: 200 }, 370 + slug: { type: 'string', minLength: 1, maxLength: 100 }, 371 + content: { type: 'string', maxLength: 100000 }, 372 + status: { type: 'string', enum: ['draft', 'published'] }, 373 + metaDescription: { type: ['string', 'null'], maxLength: 320 }, 374 + parentId: { type: ['string', 'null'] }, 375 + sortOrder: { type: 'integer', minimum: 0 }, 376 + }, 377 + }, 378 + response: { 379 + 201: pageJsonSchema, 380 + 400: errorResponseSchema, 381 + 401: errorResponseSchema, 382 + 403: errorResponseSchema, 383 + 409: errorResponseSchema, 384 + }, 385 + }, 386 + }, 387 + async (request, reply) => { 388 + const parsed = createPageSchema.safeParse(request.body) 389 + if (!parsed.success) { 390 + throw badRequest('Invalid page data') 391 + } 392 + 393 + const { title, slug, content, status, metaDescription, parentId, sortOrder } = parsed.data 394 + const communityDid = requireCommunityDid(request) 395 + 396 + // Check slug uniqueness within community 397 + const existingSlug = await db 398 + .select() 399 + .from(pages) 400 + .where(and(eq(pages.slug, slug), eq(pages.communityDid, communityDid))) 401 + 402 + if (existingSlug.length > 0) { 403 + throw conflict(`Page with slug "${slug}" already exists in this community`) 404 + } 405 + 406 + // Validate parentId if provided 407 + if (parentId !== undefined && parentId !== null) { 408 + const parentRows = await db.select().from(pages).where(eq(pages.id, parentId)) 409 + 410 + if (parentRows.length === 0) { 411 + throw badRequest(`Parent page "${parentId}" does not exist`) 412 + } 413 + } 414 + 415 + const now = new Date() 416 + const id = generateId() 417 + 418 + const inserted = await db 419 + .insert(pages) 420 + .values({ 421 + id, 422 + slug, 423 + title, 424 + content, 425 + status, 426 + metaDescription: metaDescription ?? null, 427 + parentId: parentId ?? null, 428 + sortOrder: sortOrder ?? 0, 429 + communityDid, 430 + createdAt: now, 431 + updatedAt: now, 432 + }) 433 + .returning() 434 + 435 + const created = inserted[0] 436 + if (!created) { 437 + throw badRequest('Failed to create page') 438 + } 439 + 440 + app.log.info({ pageId: id, slug, adminDid: request.user?.did }, 'Page created') 441 + 442 + return reply.status(201).send(serializePage(created)) 443 + } 444 + ) 445 + 446 + // ------------------------------------------------------------------- 447 + // PUT /api/admin/pages/:id (admin required) 448 + // ------------------------------------------------------------------- 449 + 450 + app.put( 451 + '/api/admin/pages/:id', 452 + { 453 + preHandler: [requireAdmin], 454 + schema: { 455 + tags: ['Pages (Admin)'], 456 + summary: 'Update a page', 457 + security: [{ bearerAuth: [] }], 458 + params: { 459 + type: 'object', 460 + required: ['id'], 461 + properties: { 462 + id: { type: 'string' }, 463 + }, 464 + }, 465 + body: { 466 + type: 'object', 467 + properties: { 468 + title: { type: 'string', minLength: 1, maxLength: 200 }, 469 + slug: { type: 'string', minLength: 1, maxLength: 100 }, 470 + content: { type: 'string', maxLength: 100000 }, 471 + status: { type: 'string', enum: ['draft', 'published'] }, 472 + metaDescription: { type: ['string', 'null'], maxLength: 320 }, 473 + parentId: { type: ['string', 'null'] }, 474 + sortOrder: { type: 'integer', minimum: 0 }, 475 + }, 476 + }, 477 + response: { 478 + 200: pageJsonSchema, 479 + 400: errorResponseSchema, 480 + 401: errorResponseSchema, 481 + 403: errorResponseSchema, 482 + 404: errorResponseSchema, 483 + 409: errorResponseSchema, 484 + }, 485 + }, 486 + }, 487 + async (request, reply) => { 488 + const { id } = request.params as { id: string } 489 + 490 + // Find existing page 491 + const existingRows = await db.select().from(pages).where(eq(pages.id, id)) 492 + 493 + const existing = existingRows[0] 494 + if (!existing) { 495 + throw notFound('Page not found') 496 + } 497 + 498 + const parsed = updatePageSchema.safeParse(request.body) 499 + if (!parsed.success) { 500 + throw badRequest('Invalid update data') 501 + } 502 + 503 + const updates = parsed.data 504 + const communityDid = requireCommunityDid(request) 505 + 506 + // Validate slug uniqueness if slug is being changed 507 + if (updates.slug !== undefined && updates.slug !== existing.slug) { 508 + const existingSlug = await db 509 + .select() 510 + .from(pages) 511 + .where(and(eq(pages.slug, updates.slug), eq(pages.communityDid, communityDid))) 512 + 513 + if (existingSlug.length > 0) { 514 + throw conflict(`Page with slug "${updates.slug}" already exists in this community`) 515 + } 516 + } 517 + 518 + // Validate parentId if provided (null = move to root, string = set parent) 519 + if (updates.parentId !== undefined && updates.parentId !== null) { 520 + // Check parent exists 521 + const parentRows = await db.select().from(pages).where(eq(pages.id, updates.parentId)) 522 + 523 + if (parentRows.length === 0) { 524 + throw badRequest(`Parent page "${updates.parentId}" does not exist`) 525 + } 526 + 527 + // Check for circular references 528 + if (updates.parentId === id) { 529 + throw badRequest('Page cannot be its own parent') 530 + } 531 + 532 + // Fetch all pages to check for cycles 533 + const allPages = await db 534 + .select() 535 + .from(pages) 536 + .where(eq(pages.communityDid, communityDid)) 537 + 538 + if (wouldCreateCycle(id, updates.parentId, allPages)) { 539 + throw badRequest('Setting this parent would create a circular reference') 540 + } 541 + } 542 + 543 + // Build update set 544 + const dbUpdates: Record<string, unknown> = { 545 + updatedAt: new Date(), 546 + } 547 + if (updates.title !== undefined) dbUpdates.title = updates.title 548 + if (updates.slug !== undefined) dbUpdates.slug = updates.slug 549 + if (updates.content !== undefined) dbUpdates.content = updates.content 550 + if (updates.status !== undefined) dbUpdates.status = updates.status 551 + if (updates.metaDescription !== undefined) 552 + dbUpdates.metaDescription = updates.metaDescription ?? null 553 + if (updates.parentId !== undefined) dbUpdates.parentId = updates.parentId ?? null 554 + if (updates.sortOrder !== undefined) dbUpdates.sortOrder = updates.sortOrder 555 + 556 + const updated = await db 557 + .update(pages) 558 + .set(dbUpdates) 559 + .where(eq(pages.id, id)) 560 + .returning() 561 + 562 + const updatedRow = updated[0] 563 + if (!updatedRow) { 564 + throw notFound('Page not found after update') 565 + } 566 + 567 + app.log.info( 568 + { pageId: id, updates: Object.keys(updates), adminDid: request.user?.did }, 569 + 'Page updated' 570 + ) 571 + 572 + return reply.status(200).send(serializePage(updatedRow)) 573 + } 574 + ) 575 + 576 + // ------------------------------------------------------------------- 577 + // DELETE /api/admin/pages/:id (admin required) 578 + // ------------------------------------------------------------------- 579 + 580 + app.delete( 581 + '/api/admin/pages/:id', 582 + { 583 + preHandler: [requireAdmin], 584 + schema: { 585 + tags: ['Pages (Admin)'], 586 + summary: 'Delete a page', 587 + security: [{ bearerAuth: [] }], 588 + params: { 589 + type: 'object', 590 + required: ['id'], 591 + properties: { 592 + id: { type: 'string' }, 593 + }, 594 + }, 595 + response: { 596 + 204: { type: 'null' }, 597 + 401: errorResponseSchema, 598 + 403: errorResponseSchema, 599 + 404: errorResponseSchema, 600 + 409: errorResponseSchema, 601 + }, 602 + }, 603 + }, 604 + async (request, reply) => { 605 + const { id } = request.params as { id: string } 606 + 607 + // Find existing page 608 + const existingRows = await db.select().from(pages).where(eq(pages.id, id)) 609 + 610 + const existing = existingRows[0] 611 + if (!existing) { 612 + throw notFound('Page not found') 613 + } 614 + 615 + // Check if page has children 616 + const childRows = await db.select().from(pages).where(eq(pages.parentId, id)) 617 + 618 + if (childRows.length > 0) { 619 + throw conflict( 620 + `Cannot delete page: it has ${String(childRows.length)} child page(s). Move or delete them first.` 621 + ) 622 + } 623 + 624 + // Delete the page 625 + await db.delete(pages).where(eq(pages.id, id)) 626 + 627 + app.log.info( 628 + { pageId: id, slug: existing.slug, adminDid: request.user?.did }, 629 + 'Page deleted' 630 + ) 631 + 632 + return reply.status(204).send() 633 + } 634 + ) 635 + 636 + done() 637 + } 638 + }
+237
src/setup/service.ts
··· 1 + import { randomUUID } from 'node:crypto' 1 2 import { eq, sql } from 'drizzle-orm' 2 3 import { communitySettings } from '../db/schema/community-settings.js' 3 4 import { communityOnboardingFields } from '../db/schema/onboarding-fields.js' 4 5 import { users } from '../db/schema/users.js' 6 + import { pages } from '../db/schema/pages.js' 5 7 import type { Database } from '../db/index.js' 6 8 import { encrypt } from '../lib/encryption.js' 7 9 import type { Logger } from '../lib/logger.js' ··· 206 208 .onConflictDoNothing() 207 209 logger.info({ communityDid }, 'Platform onboarding fields seeded') 208 210 211 + // Seed default pages (Terms of Service, Privacy Policy, Cookie Policy) 212 + const now = new Date() 213 + const pageDefaults = [ 214 + { 215 + id: `page-${randomUUID()}`, 216 + slug: 'terms-of-service', 217 + title: 'Terms of service', 218 + content: TERMS_OF_SERVICE_CONTENT, 219 + status: 'published' as const, 220 + metaDescription: 'Terms and conditions for using this forum community.', 221 + parentId: null, 222 + sortOrder: 0, 223 + communityDid, 224 + createdAt: now, 225 + updatedAt: now, 226 + }, 227 + { 228 + id: `page-${randomUUID()}`, 229 + slug: 'privacy-policy', 230 + title: 'Privacy policy', 231 + content: PRIVACY_POLICY_CONTENT, 232 + status: 'published' as const, 233 + metaDescription: 'How we collect, use, and protect your personal data.', 234 + parentId: null, 235 + sortOrder: 1, 236 + communityDid, 237 + createdAt: now, 238 + updatedAt: now, 239 + }, 240 + { 241 + id: `page-${randomUUID()}`, 242 + slug: 'cookie-policy', 243 + title: 'Cookie policy', 244 + content: COOKIE_POLICY_CONTENT, 245 + status: 'published' as const, 246 + metaDescription: 'Information about the cookies used on this forum.', 247 + parentId: null, 248 + sortOrder: 2, 249 + communityDid, 250 + createdAt: now, 251 + updatedAt: now, 252 + }, 253 + ] 254 + await db.insert(pages).values(pageDefaults) 255 + logger.info({ communityDid, pageCount: pageDefaults.length }, 'Default pages seeded') 256 + 209 257 const finalName = row.communityName 210 258 logger.info({ did, communityName: finalName }, 'Community initialized') 211 259 ··· 228 276 229 277 return { getStatus, initialize } 230 278 } 279 + 280 + // --------------------------------------------------------------------------- 281 + // Default page content (markdown) 282 + // --------------------------------------------------------------------------- 283 + 284 + const TERMS_OF_SERVICE_CONTENT = `## Acceptance of terms 285 + 286 + 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. 287 + 288 + ## Eligibility 289 + 290 + 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. 291 + 292 + ## Account and authentication 293 + 294 + 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. 295 + 296 + ## Content and conduct 297 + 298 + 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. 299 + 300 + You agree not to post content that: 301 + 302 + - Violates applicable laws or regulations. 303 + - Infringes on the intellectual property rights of others. 304 + - Contains spam, malware, or deceptive content. 305 + - Harasses, threatens, or promotes violence against individuals or groups. 306 + - Contains child sexual abuse material (CSAM). 307 + 308 + Community administrators may enforce additional content policies specific to their community. Repeated violations may result in content removal, account restrictions, or bans. 309 + 310 + ## Content maturity ratings 311 + 312 + 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. 313 + 314 + ## Cross-posting 315 + 316 + 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. 317 + 318 + ## Moderation and labels 319 + 320 + 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. 321 + 322 + ## AI-generated summaries 323 + 324 + 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. 325 + 326 + ## AT Protocol and federation 327 + 328 + 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. 329 + 330 + ## Termination 331 + 332 + 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). 333 + 334 + ## Limitation of liability 335 + 336 + 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. 337 + 338 + ## Governing law 339 + 340 + 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. 341 + 342 + *These terms were last updated on February 2026.*` 343 + 344 + const PRIVACY_POLICY_CONTENT = `## Overview 345 + 346 + 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). 347 + 348 + ## What we collect 349 + 350 + When you use Barazo, we process the following data: 351 + 352 + - **AT Protocol identifiers** -- your DID (decentralized identifier) and handle, used to identify your account. 353 + - **Profile information** -- display name and profile data retrieved from your AT Protocol PDS. 354 + - **Content** -- posts, replies, and reactions you create on the forum, indexed from the AT Protocol firehose. 355 + - **IP addresses** -- collected for API rate limiting and security purposes. 356 + - **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. 357 + - **Moderation records** -- actions taken by moderators on your content or account. 358 + - **Age declaration** -- stored in the forum database only (deliberately kept off your PDS to avoid broadcasting age data on a public network). 359 + - **Per-community preferences** -- notification settings and content maturity overrides, stored locally in the forum database (not on your PDS) to protect your browsing patterns. 360 + 361 + ## What we do not collect 362 + 363 + - We do not collect or store your password (authentication is handled via AT Protocol OAuth). 364 + - We do not collect email addresses unless provided by a community admin for billing. 365 + - We do not collect payment card details (processed by our payment provider). 366 + - We do not use tracking cookies or analytics that profile your behavior. 367 + - We do not use device fingerprinting. 368 + - We do not load third-party trackers, pixels, or analytics scripts. 369 + 370 + ## Legal basis 371 + 372 + We process your data under the following legal bases (GDPR Art. 6): 373 + 374 + - **Contract performance** -- processing necessary to provide the forum service you signed up for. 375 + - **Legitimate interest** -- indexing public AT Protocol content, spam prevention, platform security, content moderation, and AI-generated discussion summaries. 376 + 377 + ## Data storage and transfers 378 + 379 + Our servers are hosted in the European Union (Hetzner, Germany). We use the following sub-processors: 380 + 381 + - Hetzner (EU) -- hosting infrastructure. 382 + - Bunny.net (EU, Slovenia) -- content delivery network. 383 + - Stripe (EU-US Data Privacy Framework certified) -- payment processing. 384 + 385 + A full sub-processor list is maintained at **barazo.forum/legal/sub-processors**. 386 + 387 + ## Data retention and deletion 388 + 389 + 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: 390 + 391 + - Your post is removed from public view and replaced with a "deleted by author" notice. 392 + - Your personal data (DID, handle, AT Protocol URI) is stripped from the database record. 393 + - 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. 394 + 395 + 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)). 396 + 397 + 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. 398 + 399 + ## AI features 400 + 401 + Barazo offers optional AI features including thread summaries, semantic search, and content moderation assistance. Here is how they work: 402 + 403 + - **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. 404 + - **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. 405 + - **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. 406 + 407 + ## Content labels 408 + 409 + 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. 410 + 411 + ## Your rights 412 + 413 + Under the GDPR, you have the right to: 414 + 415 + - Access the personal data we hold about you. 416 + - Rectify inaccurate data. 417 + - Request erasure of your data (right to be forgotten). 418 + - Object to processing based on legitimate interest. 419 + - Data portability (built into the AT Protocol). 420 + - Lodge a complaint with the Dutch Data Protection Authority (Autoriteit Persoonsgegevens). 421 + 422 + 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. 423 + 424 + ## Data breach notification 425 + 426 + 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. 427 + 428 + *This policy was last updated on February 2026.*` 429 + 430 + const COOKIE_POLICY_CONTENT = `## Overview 431 + 432 + 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. 433 + 434 + ## Cookies we use 435 + 436 + Barazo uses a single essential cookie: 437 + 438 + | Cookie | Purpose | Duration | Type | 439 + |--------|---------|----------|------| 440 + | Refresh token | Keeps you logged in across page reloads by enabling silent access token renewal. | Session | Essential | 441 + 442 + ## Technical details 443 + 444 + The refresh token cookie has the following security properties: 445 + 446 + - **HTTP-only** -- the cookie is not accessible to JavaScript, preventing cross-site scripting (XSS) attacks. 447 + - **Secure** -- the cookie is only sent over HTTPS connections. 448 + - **SameSite=Strict** -- the cookie is not sent with cross-site requests, preventing cross-site request forgery (CSRF) attacks. 449 + 450 + Access tokens (used to authenticate API requests) are held in memory only and are never stored in cookies, localStorage, or sessionStorage. 451 + 452 + ## What we do not use 453 + 454 + - No tracking or advertising cookies. 455 + - No third-party analytics (Google Analytics, etc.). 456 + - No social media tracking pixels. 457 + - No fingerprinting or behavioral profiling. 458 + 459 + ## Cookie consent 460 + 461 + 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. 462 + 463 + ## Theme preference 464 + 465 + 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. 466 + 467 + *This policy was last updated on February 2026.*`
+146
src/validation/pages.ts
··· 1 + import { z } from 'zod/v4' 2 + 3 + // --------------------------------------------------------------------------- 4 + // Shared enums 5 + // --------------------------------------------------------------------------- 6 + 7 + /** Valid page status values. */ 8 + export const pageStatusSchema = z.enum(['draft', 'published']) 9 + 10 + export type PageStatus = z.infer<typeof pageStatusSchema> 11 + 12 + // --------------------------------------------------------------------------- 13 + // Constants 14 + // --------------------------------------------------------------------------- 15 + 16 + /** Slugs reserved for UI routes that must not collide with page slugs. */ 17 + const RESERVED_SLUGS = ['new', 'edit', 'drafts'] as const 18 + 19 + // --------------------------------------------------------------------------- 20 + // Request schemas 21 + // --------------------------------------------------------------------------- 22 + 23 + /** Slug pattern: lowercase alphanumeric segments separated by single hyphens. */ 24 + const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ 25 + 26 + /** Slug field with format validation and reserved-slug rejection. */ 27 + const slugField = z 28 + .string() 29 + .min(1, 'Slug is required') 30 + .max(100, 'Slug must be at most 100 characters') 31 + .regex( 32 + slugPattern, 33 + "Slug must be lowercase alphanumeric with single hyphens (e.g. 'terms-of-service')" 34 + ) 35 + .refine( 36 + (val) => !RESERVED_SLUGS.includes(val as (typeof RESERVED_SLUGS)[number]), 37 + 'This slug is reserved and cannot be used' 38 + ) 39 + 40 + /** Schema for creating a new page. */ 41 + export const createPageSchema = z.object({ 42 + title: z 43 + .string() 44 + .trim() 45 + .min(1, 'Title is required') 46 + .max(200, 'Title must be at most 200 characters'), 47 + slug: slugField, 48 + content: z.string().max(100_000, 'Content must be at most 100000 characters').default(''), 49 + status: pageStatusSchema.default('draft'), 50 + metaDescription: z 51 + .string() 52 + .max(320, 'Meta description must be at most 320 characters') 53 + .nullable() 54 + .optional(), 55 + parentId: z.string().nullable().optional(), 56 + sortOrder: z 57 + .number() 58 + .int('Sort order must be an integer') 59 + .min(0, 'Sort order must be non-negative') 60 + .optional(), 61 + }) 62 + 63 + export type CreatePageInput = z.infer<typeof createPageSchema> 64 + 65 + /** Schema for updating an existing page (all fields optional). */ 66 + export const updatePageSchema = z.object({ 67 + title: z 68 + .string() 69 + .trim() 70 + .min(1, 'Title must not be empty') 71 + .max(200, 'Title must be at most 200 characters') 72 + .optional(), 73 + slug: slugField.optional(), 74 + content: z 75 + .string() 76 + .max(100_000, 'Content must be at most 100000 characters') 77 + .optional(), 78 + status: pageStatusSchema.optional(), 79 + metaDescription: z 80 + .string() 81 + .max(320, 'Meta description must be at most 320 characters') 82 + .nullable() 83 + .optional(), 84 + parentId: z.string().nullable().optional(), 85 + sortOrder: z 86 + .number() 87 + .int('Sort order must be an integer') 88 + .min(0, 'Sort order must be non-negative') 89 + .optional(), 90 + }) 91 + 92 + export type UpdatePageInput = z.infer<typeof updatePageSchema> 93 + 94 + // --------------------------------------------------------------------------- 95 + // Response schemas (for OpenAPI documentation) 96 + // --------------------------------------------------------------------------- 97 + 98 + /** Schema describing a single page in API responses. */ 99 + export const pageResponseSchema = z.object({ 100 + id: z.string(), 101 + slug: z.string(), 102 + title: z.string(), 103 + content: z.string(), 104 + status: pageStatusSchema, 105 + metaDescription: z.string().nullable(), 106 + parentId: z.string().nullable(), 107 + sortOrder: z.number(), 108 + communityDid: z.string(), 109 + createdAt: z.string(), 110 + updatedAt: z.string(), 111 + }) 112 + 113 + export type PageResponse = z.infer<typeof pageResponseSchema> 114 + 115 + /** Schema describing a page with its children (tree structure). */ 116 + export const pageTreeResponseSchema: z.ZodType<PageTreeResponse> = z.lazy(() => 117 + z.object({ 118 + id: z.string(), 119 + slug: z.string(), 120 + title: z.string(), 121 + content: z.string(), 122 + status: pageStatusSchema, 123 + metaDescription: z.string().nullable(), 124 + parentId: z.string().nullable(), 125 + sortOrder: z.number(), 126 + communityDid: z.string(), 127 + createdAt: z.string(), 128 + updatedAt: z.string(), 129 + children: z.array(pageTreeResponseSchema), 130 + }) 131 + ) 132 + 133 + export interface PageTreeResponse { 134 + id: string 135 + slug: string 136 + title: string 137 + content: string 138 + status: 'draft' | 'published' 139 + metaDescription: string | null 140 + parentId: string | null 141 + sortOrder: number 142 + communityDid: string 143 + createdAt: string 144 + updatedAt: string 145 + children: PageTreeResponse[] 146 + }
+67
tests/unit/db/schema/pages.test.ts
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { getTableName, getTableColumns } from 'drizzle-orm' 3 + import { pages } from '../../../../src/db/schema/pages.js' 4 + 5 + describe('pages schema', () => { 6 + const columns = getTableColumns(pages) 7 + 8 + it('has the correct table name', () => { 9 + expect(getTableName(pages)).toBe('pages') 10 + }) 11 + 12 + it('uses id as primary key', () => { 13 + expect(columns.id.primary).toBe(true) 14 + }) 15 + 16 + it('has all required columns', () => { 17 + const columnNames = Object.keys(columns) 18 + 19 + const expected = [ 20 + 'id', 21 + 'slug', 22 + 'title', 23 + 'content', 24 + 'status', 25 + 'metaDescription', 26 + 'parentId', 27 + 'sortOrder', 28 + 'communityDid', 29 + 'createdAt', 30 + 'updatedAt', 31 + ] 32 + 33 + for (const col of expected) { 34 + expect(columnNames).toContain(col) 35 + } 36 + }) 37 + 38 + it('has non-nullable required columns', () => { 39 + expect(columns.id.notNull).toBe(true) 40 + expect(columns.slug.notNull).toBe(true) 41 + expect(columns.title.notNull).toBe(true) 42 + expect(columns.content.notNull).toBe(true) 43 + expect(columns.status.notNull).toBe(true) 44 + expect(columns.sortOrder.notNull).toBe(true) 45 + expect(columns.communityDid.notNull).toBe(true) 46 + expect(columns.createdAt.notNull).toBe(true) 47 + expect(columns.updatedAt.notNull).toBe(true) 48 + }) 49 + 50 + it('has nullable optional columns', () => { 51 + expect(columns.metaDescription.notNull).toBe(false) 52 + expect(columns.parentId.notNull).toBe(false) 53 + }) 54 + 55 + it('has default value for sortOrder', () => { 56 + expect(columns.sortOrder.hasDefault).toBe(true) 57 + }) 58 + 59 + it('has default value for status', () => { 60 + expect(columns.status.hasDefault).toBe(true) 61 + }) 62 + 63 + it('has default values for timestamps', () => { 64 + expect(columns.createdAt.hasDefault).toBe(true) 65 + expect(columns.updatedAt.hasDefault).toBe(true) 66 + }) 67 + })
+761
tests/unit/routes/pages.test.ts
··· 1 + import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' 2 + import Fastify from 'fastify' 3 + import type { FastifyInstance } from 'fastify' 4 + import type { Env } from '../../../src/config/env.js' 5 + import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' 6 + import type { SessionService } from '../../../src/auth/session.js' 7 + import type { SetupService } from '../../../src/setup/service.js' 8 + import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' 9 + 10 + /* eslint-disable @typescript-eslint/no-misused-promises -- Mock implementations return thenable objects by design */ 11 + 12 + // Import routes (no PDS mocking needed -- pages are local-only) 13 + import { pageRoutes } from '../../../src/routes/pages.js' 14 + 15 + // --------------------------------------------------------------------------- 16 + // Mock env (minimal subset for page routes) 17 + // --------------------------------------------------------------------------- 18 + 19 + const mockEnv = { 20 + COMMUNITY_DID: 'did:plc:community123', 21 + RATE_LIMIT_WRITE: 10, 22 + RATE_LIMIT_READ_ANON: 100, 23 + RATE_LIMIT_READ_AUTH: 300, 24 + } as Env 25 + 26 + // --------------------------------------------------------------------------- 27 + // Test constants 28 + // --------------------------------------------------------------------------- 29 + 30 + const TEST_DID = 'did:plc:testuser123' 31 + const TEST_HANDLE = 'jay.bsky.team' 32 + const TEST_SID = 'a'.repeat(64) 33 + const ADMIN_DID = 'did:plc:admin999' 34 + const TEST_NOW = '2026-02-13T12:00:00.000Z' 35 + const COMMUNITY_DID = 'did:plc:test' 36 + 37 + const PAGE_ID_1 = 'page-001' 38 + const PAGE_ID_2 = 'page-002' 39 + 40 + // --------------------------------------------------------------------------- 41 + // Mock user builders 42 + // --------------------------------------------------------------------------- 43 + 44 + function testUser(overrides?: Partial<RequestUser>): RequestUser { 45 + return { 46 + did: TEST_DID, 47 + handle: TEST_HANDLE, 48 + sid: TEST_SID, 49 + ...overrides, 50 + } 51 + } 52 + 53 + function adminUser(): RequestUser { 54 + return testUser({ did: ADMIN_DID, handle: 'admin.bsky.social' }) 55 + } 56 + 57 + // --------------------------------------------------------------------------- 58 + // Chainable mock DB (shared helper) 59 + // --------------------------------------------------------------------------- 60 + 61 + const mockDb = createMockDb() 62 + 63 + let insertChain: DbChain 64 + let selectChain: DbChain 65 + let updateChain: DbChain 66 + let deleteChain: DbChain 67 + 68 + function resetAllDbMocks(): void { 69 + insertChain = createChainableProxy() 70 + selectChain = createChainableProxy([]) 71 + updateChain = createChainableProxy([]) 72 + deleteChain = createChainableProxy() 73 + mockDb.insert.mockReturnValue(insertChain) 74 + mockDb.select.mockReturnValue(selectChain) 75 + mockDb.update.mockReturnValue(updateChain) 76 + mockDb.delete.mockReturnValue(deleteChain) 77 + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction 78 + mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise<void>) => { 79 + await fn(mockDb) 80 + }) 81 + } 82 + 83 + // --------------------------------------------------------------------------- 84 + // Auth middleware mocks 85 + // --------------------------------------------------------------------------- 86 + 87 + function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { 88 + return { 89 + requireAuth: async (request, reply) => { 90 + if (!user) { 91 + await reply.status(401).send({ error: 'Authentication required' }) 92 + return 93 + } 94 + request.user = user 95 + }, 96 + optionalAuth: (request, _reply) => { 97 + if (user) { 98 + request.user = user 99 + } 100 + return Promise.resolve() 101 + }, 102 + } 103 + } 104 + 105 + // --------------------------------------------------------------------------- 106 + // Mock requireAdmin factory 107 + // --------------------------------------------------------------------------- 108 + 109 + function createMockRequireAdmin(user?: RequestUser) { 110 + return async ( 111 + request: { user?: RequestUser }, 112 + reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise<void> } } 113 + ) => { 114 + if (!user) { 115 + await reply.status(401).send({ error: 'Authentication required' }) 116 + return 117 + } 118 + request.user = user 119 + if (user.did !== ADMIN_DID) { 120 + await reply.status(403).send({ error: 'Admin access required' }) 121 + return 122 + } 123 + } 124 + } 125 + 126 + // --------------------------------------------------------------------------- 127 + // Sample page row (as returned from DB) 128 + // --------------------------------------------------------------------------- 129 + 130 + function samplePageRow(overrides?: Record<string, unknown>) { 131 + return { 132 + id: PAGE_ID_1, 133 + slug: 'terms-of-service', 134 + title: 'Terms of Service', 135 + content: '## Acceptance of terms\n\nBy accessing or using this forum...', 136 + status: 'published', 137 + metaDescription: 'Terms and conditions for using this forum.', 138 + parentId: null, 139 + sortOrder: 0, 140 + communityDid: COMMUNITY_DID, 141 + createdAt: new Date(TEST_NOW), 142 + updatedAt: new Date(TEST_NOW), 143 + ...overrides, 144 + } 145 + } 146 + 147 + // --------------------------------------------------------------------------- 148 + // Helper: build app with mocked deps 149 + // --------------------------------------------------------------------------- 150 + 151 + async function buildTestApp(user?: RequestUser): Promise<FastifyInstance> { 152 + const app = Fastify({ logger: false }) 153 + 154 + const authMiddleware = createMockAuthMiddleware(user) 155 + const requireAdmin = createMockRequireAdmin(user) 156 + 157 + app.decorate('db', mockDb as never) 158 + app.decorate('env', mockEnv) 159 + app.decorate('authMiddleware', authMiddleware) 160 + app.decorate('requireAdmin', requireAdmin as never) 161 + app.decorate('firehose', {} as never) 162 + app.decorate('oauthClient', {} as never) 163 + app.decorate('sessionService', {} as SessionService) 164 + app.decorate('setupService', {} as SetupService) 165 + app.decorate('cache', {} as never) 166 + app.decorateRequest('user', undefined as RequestUser | undefined) 167 + app.decorateRequest('communityDid', undefined as string | undefined) 168 + app.addHook('onRequest', (request, _reply, done) => { 169 + request.communityDid = COMMUNITY_DID 170 + done() 171 + }) 172 + 173 + await app.register(pageRoutes()) 174 + await app.ready() 175 + 176 + return app 177 + } 178 + 179 + // =========================================================================== 180 + // Test suite 181 + // =========================================================================== 182 + 183 + describe('page routes', () => { 184 + // ========================================================================= 185 + // PUBLIC: GET /api/pages (list published pages as tree) 186 + // ========================================================================= 187 + 188 + describe('GET /api/pages', () => { 189 + let app: FastifyInstance 190 + 191 + beforeAll(async () => { 192 + app = await buildTestApp() 193 + }) 194 + 195 + afterAll(async () => { 196 + await app.close() 197 + }) 198 + 199 + beforeEach(() => { 200 + vi.clearAllMocks() 201 + resetAllDbMocks() 202 + }) 203 + 204 + it('returns published pages as a tree with content snippets', async () => { 205 + const longContent = 'A'.repeat(300) 206 + selectChain.where.mockImplementation(() => ({ 207 + ...selectChain, 208 + then: (resolve: (v: unknown) => void) => 209 + { resolve([ 210 + samplePageRow({ content: longContent }), 211 + ]); }, 212 + })) 213 + 214 + const response = await app.inject({ 215 + method: 'GET', 216 + url: '/api/pages', 217 + }) 218 + 219 + expect(response.statusCode).toBe(200) 220 + const body = response.json<{ pages: Array<{ content: string; children: unknown[] }> }>() 221 + expect(body.pages).toHaveLength(1) 222 + // Content should be a snippet (first 200 chars + '...' ellipsis) 223 + expect(body.pages[0].content.length).toBeLessThanOrEqual(203) 224 + expect(body.pages[0].content).toMatch(/\.\.\.$/) 225 + expect(body.pages[0].children).toEqual([]) 226 + }) 227 + 228 + it('returns empty array when no published pages exist', async () => { 229 + const response = await app.inject({ 230 + method: 'GET', 231 + url: '/api/pages', 232 + }) 233 + 234 + expect(response.statusCode).toBe(200) 235 + expect(response.json<{ pages: unknown[] }>().pages).toEqual([]) 236 + }) 237 + }) 238 + 239 + // ========================================================================= 240 + // PUBLIC: GET /api/pages/:slug (single published page) 241 + // ========================================================================= 242 + 243 + describe('GET /api/pages/:slug', () => { 244 + let app: FastifyInstance 245 + 246 + beforeAll(async () => { 247 + app = await buildTestApp() 248 + }) 249 + 250 + afterAll(async () => { 251 + await app.close() 252 + }) 253 + 254 + beforeEach(() => { 255 + vi.clearAllMocks() 256 + resetAllDbMocks() 257 + }) 258 + 259 + it('returns a published page with full content', async () => { 260 + selectChain.where.mockImplementation(() => ({ 261 + ...selectChain, 262 + then: (resolve: (v: unknown) => void) => 263 + { resolve([samplePageRow()]); }, 264 + })) 265 + 266 + const response = await app.inject({ 267 + method: 'GET', 268 + url: '/api/pages/terms-of-service', 269 + }) 270 + 271 + expect(response.statusCode).toBe(200) 272 + const body = response.json<{ slug: string; content: string }>() 273 + expect(body.slug).toBe('terms-of-service') 274 + expect(body.content).toBe('## Acceptance of terms\n\nBy accessing or using this forum...') 275 + }) 276 + 277 + it('returns 404 for a draft page (filtered out by query)', async () => { 278 + // With status filter in the WHERE clause, a draft page is not returned by the DB 279 + selectChain.where.mockImplementation(() => ({ 280 + ...selectChain, 281 + then: (resolve: (v: unknown) => void) => { resolve([]); }, 282 + })) 283 + 284 + const response = await app.inject({ 285 + method: 'GET', 286 + url: '/api/pages/terms-of-service', 287 + }) 288 + 289 + expect(response.statusCode).toBe(404) 290 + }) 291 + 292 + it('returns 404 for non-existent page', async () => { 293 + const response = await app.inject({ 294 + method: 'GET', 295 + url: '/api/pages/non-existent', 296 + }) 297 + 298 + expect(response.statusCode).toBe(404) 299 + }) 300 + }) 301 + 302 + // ========================================================================= 303 + // ADMIN: GET /api/admin/pages (list ALL pages as tree) 304 + // ========================================================================= 305 + 306 + describe('GET /api/admin/pages', () => { 307 + let app: FastifyInstance 308 + 309 + beforeAll(async () => { 310 + app = await buildTestApp(adminUser()) 311 + }) 312 + 313 + afterAll(async () => { 314 + await app.close() 315 + }) 316 + 317 + beforeEach(() => { 318 + vi.clearAllMocks() 319 + resetAllDbMocks() 320 + }) 321 + 322 + it('returns all pages including drafts', async () => { 323 + selectChain.where.mockImplementation(() => ({ 324 + ...selectChain, 325 + then: (resolve: (v: unknown) => void) => 326 + { resolve([ 327 + samplePageRow(), 328 + samplePageRow({ id: PAGE_ID_2, slug: 'draft-page', status: 'draft', sortOrder: 1 }), 329 + ]); }, 330 + })) 331 + 332 + const response = await app.inject({ 333 + method: 'GET', 334 + url: '/api/admin/pages', 335 + }) 336 + 337 + expect(response.statusCode).toBe(200) 338 + const body = response.json<{ pages: Array<{ id: string; status: string }> }>() 339 + expect(body.pages).toHaveLength(2) 340 + }) 341 + 342 + it('returns 401 without auth', async () => { 343 + const noAuthApp = await buildTestApp() 344 + const response = await noAuthApp.inject({ 345 + method: 'GET', 346 + url: '/api/admin/pages', 347 + }) 348 + expect(response.statusCode).toBe(401) 349 + await noAuthApp.close() 350 + }) 351 + 352 + it('returns 403 for non-admin user', async () => { 353 + const regularApp = await buildTestApp(testUser()) 354 + const response = await regularApp.inject({ 355 + method: 'GET', 356 + url: '/api/admin/pages', 357 + }) 358 + expect(response.statusCode).toBe(403) 359 + await regularApp.close() 360 + }) 361 + }) 362 + 363 + // ========================================================================= 364 + // ADMIN: GET /api/admin/pages/:id (single page by ID) 365 + // ========================================================================= 366 + 367 + describe('GET /api/admin/pages/:id', () => { 368 + let app: FastifyInstance 369 + 370 + beforeAll(async () => { 371 + app = await buildTestApp(adminUser()) 372 + }) 373 + 374 + afterAll(async () => { 375 + await app.close() 376 + }) 377 + 378 + beforeEach(() => { 379 + vi.clearAllMocks() 380 + resetAllDbMocks() 381 + }) 382 + 383 + it('returns a page by ID with full content', async () => { 384 + selectChain.where.mockImplementation(() => ({ 385 + ...selectChain, 386 + then: (resolve: (v: unknown) => void) => 387 + { resolve([samplePageRow()]); }, 388 + })) 389 + 390 + const response = await app.inject({ 391 + method: 'GET', 392 + url: `/api/admin/pages/${PAGE_ID_1}`, 393 + }) 394 + 395 + expect(response.statusCode).toBe(200) 396 + const body = response.json<{ id: string; content: string }>() 397 + expect(body.id).toBe(PAGE_ID_1) 398 + }) 399 + 400 + it('returns 404 for non-existent page', async () => { 401 + const response = await app.inject({ 402 + method: 'GET', 403 + url: '/api/admin/pages/page-nonexistent', 404 + }) 405 + 406 + expect(response.statusCode).toBe(404) 407 + }) 408 + }) 409 + 410 + // ========================================================================= 411 + // ADMIN: POST /api/admin/pages (create page) 412 + // ========================================================================= 413 + 414 + describe('POST /api/admin/pages', () => { 415 + let app: FastifyInstance 416 + 417 + beforeAll(async () => { 418 + app = await buildTestApp(adminUser()) 419 + }) 420 + 421 + afterAll(async () => { 422 + await app.close() 423 + }) 424 + 425 + beforeEach(() => { 426 + vi.clearAllMocks() 427 + resetAllDbMocks() 428 + }) 429 + 430 + it('creates a page with valid input', async () => { 431 + const created = samplePageRow() 432 + 433 + // Slug uniqueness check returns empty (no conflict) 434 + selectChain.where.mockImplementationOnce(() => ({ 435 + ...selectChain, 436 + then: (resolve: (v: unknown) => void) => { resolve([]); }, 437 + })) 438 + 439 + // Insert returning 440 + insertChain.returning.mockImplementationOnce(() => ({ 441 + ...insertChain, 442 + then: (resolve: (v: unknown) => void) => { resolve([created]); }, 443 + })) 444 + 445 + const response = await app.inject({ 446 + method: 'POST', 447 + url: '/api/admin/pages', 448 + payload: { 449 + title: 'Terms of Service', 450 + slug: 'terms-of-service', 451 + content: '## Acceptance of terms\n\nBy accessing or using this forum...', 452 + status: 'published', 453 + metaDescription: 'Terms and conditions for using this forum.', 454 + }, 455 + }) 456 + 457 + expect(response.statusCode).toBe(201) 458 + const body = response.json<{ slug: string }>() 459 + expect(body.slug).toBe('terms-of-service') 460 + }) 461 + 462 + it('returns 400 for invalid input (missing title)', async () => { 463 + const response = await app.inject({ 464 + method: 'POST', 465 + url: '/api/admin/pages', 466 + payload: { 467 + slug: 'no-title', 468 + }, 469 + }) 470 + 471 + expect(response.statusCode).toBe(400) 472 + }) 473 + 474 + it('returns 400 for reserved slug', async () => { 475 + const response = await app.inject({ 476 + method: 'POST', 477 + url: '/api/admin/pages', 478 + payload: { 479 + title: 'New Page', 480 + slug: 'new', 481 + }, 482 + }) 483 + 484 + expect(response.statusCode).toBe(400) 485 + }) 486 + 487 + it('returns 409 for duplicate slug in same community', async () => { 488 + // Slug uniqueness check returns existing page 489 + selectChain.where.mockImplementationOnce(() => ({ 490 + ...selectChain, 491 + then: (resolve: (v: unknown) => void) => { resolve([samplePageRow()]); }, 492 + })) 493 + 494 + const response = await app.inject({ 495 + method: 'POST', 496 + url: '/api/admin/pages', 497 + payload: { 498 + title: 'Duplicate', 499 + slug: 'terms-of-service', 500 + }, 501 + }) 502 + 503 + expect(response.statusCode).toBe(409) 504 + }) 505 + 506 + it('returns 400 for non-existent parentId', async () => { 507 + // Slug uniqueness check returns empty 508 + selectChain.where.mockImplementationOnce(() => ({ 509 + ...selectChain, 510 + then: (resolve: (v: unknown) => void) => { resolve([]); }, 511 + })) 512 + // Parent check returns empty 513 + selectChain.where.mockImplementationOnce(() => ({ 514 + ...selectChain, 515 + then: (resolve: (v: unknown) => void) => { resolve([]); }, 516 + })) 517 + 518 + const response = await app.inject({ 519 + method: 'POST', 520 + url: '/api/admin/pages', 521 + payload: { 522 + title: 'Child Page', 523 + slug: 'child', 524 + parentId: 'page-nonexistent', 525 + }, 526 + }) 527 + 528 + expect(response.statusCode).toBe(400) 529 + }) 530 + 531 + it('returns 401 without auth', async () => { 532 + const noAuthApp = await buildTestApp() 533 + const response = await noAuthApp.inject({ 534 + method: 'POST', 535 + url: '/api/admin/pages', 536 + payload: { title: 'Test', slug: 'test' }, 537 + }) 538 + expect(response.statusCode).toBe(401) 539 + await noAuthApp.close() 540 + }) 541 + }) 542 + 543 + // ========================================================================= 544 + // ADMIN: PUT /api/admin/pages/:id (update page) 545 + // ========================================================================= 546 + 547 + describe('PUT /api/admin/pages/:id', () => { 548 + let app: FastifyInstance 549 + 550 + beforeAll(async () => { 551 + app = await buildTestApp(adminUser()) 552 + }) 553 + 554 + afterAll(async () => { 555 + await app.close() 556 + }) 557 + 558 + beforeEach(() => { 559 + vi.clearAllMocks() 560 + resetAllDbMocks() 561 + }) 562 + 563 + it('updates a page title', async () => { 564 + const existing = samplePageRow() 565 + const updated = { ...existing, title: 'Updated Title', updatedAt: new Date() } 566 + 567 + // Find existing 568 + selectChain.where.mockImplementationOnce(() => ({ 569 + ...selectChain, 570 + then: (resolve: (v: unknown) => void) => { resolve([existing]); }, 571 + })) 572 + 573 + // Update returning 574 + updateChain.returning.mockImplementationOnce(() => ({ 575 + ...updateChain, 576 + then: (resolve: (v: unknown) => void) => { resolve([updated]); }, 577 + })) 578 + 579 + const response = await app.inject({ 580 + method: 'PUT', 581 + url: `/api/admin/pages/${PAGE_ID_1}`, 582 + payload: { title: 'Updated Title' }, 583 + }) 584 + 585 + expect(response.statusCode).toBe(200) 586 + const body = response.json<{ title: string }>() 587 + expect(body.title).toBe('Updated Title') 588 + }) 589 + 590 + it('returns 404 for non-existent page', async () => { 591 + const response = await app.inject({ 592 + method: 'PUT', 593 + url: '/api/admin/pages/page-nonexistent', 594 + payload: { title: 'Updated' }, 595 + }) 596 + 597 + expect(response.statusCode).toBe(404) 598 + }) 599 + 600 + it('returns 409 for duplicate slug on update', async () => { 601 + const existing = samplePageRow() 602 + 603 + // Find existing 604 + selectChain.where.mockImplementationOnce(() => ({ 605 + ...selectChain, 606 + then: (resolve: (v: unknown) => void) => { resolve([existing]); }, 607 + })) 608 + 609 + // Slug uniqueness check returns existing page with different ID 610 + selectChain.where.mockImplementationOnce(() => ({ 611 + ...selectChain, 612 + then: (resolve: (v: unknown) => void) => 613 + { resolve([samplePageRow({ id: PAGE_ID_2, slug: 'other-slug' })]); }, 614 + })) 615 + 616 + const response = await app.inject({ 617 + method: 'PUT', 618 + url: `/api/admin/pages/${PAGE_ID_1}`, 619 + payload: { slug: 'other-slug' }, 620 + }) 621 + 622 + expect(response.statusCode).toBe(409) 623 + }) 624 + 625 + it('detects circular references when updating parentId', async () => { 626 + const parent = samplePageRow({ id: PAGE_ID_1, parentId: null }) 627 + const child = samplePageRow({ id: PAGE_ID_2, parentId: PAGE_ID_1 }) 628 + 629 + // Find existing (the parent page) 630 + selectChain.where.mockImplementationOnce(() => ({ 631 + ...selectChain, 632 + then: (resolve: (v: unknown) => void) => { resolve([parent]); }, 633 + })) 634 + 635 + // Parent exists check 636 + selectChain.where.mockImplementationOnce(() => ({ 637 + ...selectChain, 638 + then: (resolve: (v: unknown) => void) => { resolve([child]); }, 639 + })) 640 + 641 + // Fetch all pages for cycle detection 642 + selectChain.where.mockImplementationOnce(() => ({ 643 + ...selectChain, 644 + then: (resolve: (v: unknown) => void) => { resolve([parent, child]); }, 645 + })) 646 + 647 + const response = await app.inject({ 648 + method: 'PUT', 649 + url: `/api/admin/pages/${PAGE_ID_1}`, 650 + payload: { parentId: PAGE_ID_2 }, 651 + }) 652 + 653 + expect(response.statusCode).toBe(400) 654 + }) 655 + 656 + it('rejects self-reference parentId', async () => { 657 + const existing = samplePageRow() 658 + 659 + // Find existing 660 + selectChain.where.mockImplementationOnce(() => ({ 661 + ...selectChain, 662 + then: (resolve: (v: unknown) => void) => { resolve([existing]); }, 663 + })) 664 + 665 + // Parent exists check (returns existing which is the same page) 666 + selectChain.where.mockImplementationOnce(() => ({ 667 + ...selectChain, 668 + then: (resolve: (v: unknown) => void) => { resolve([existing]); }, 669 + })) 670 + 671 + const response = await app.inject({ 672 + method: 'PUT', 673 + url: `/api/admin/pages/${PAGE_ID_1}`, 674 + payload: { parentId: PAGE_ID_1 }, 675 + }) 676 + 677 + expect(response.statusCode).toBe(400) 678 + }) 679 + }) 680 + 681 + // ========================================================================= 682 + // ADMIN: DELETE /api/admin/pages/:id (delete page) 683 + // ========================================================================= 684 + 685 + describe('DELETE /api/admin/pages/:id', () => { 686 + let app: FastifyInstance 687 + 688 + beforeAll(async () => { 689 + app = await buildTestApp(adminUser()) 690 + }) 691 + 692 + afterAll(async () => { 693 + await app.close() 694 + }) 695 + 696 + beforeEach(() => { 697 + vi.clearAllMocks() 698 + resetAllDbMocks() 699 + }) 700 + 701 + it('deletes a page with no children', async () => { 702 + // Find existing 703 + selectChain.where.mockImplementationOnce(() => ({ 704 + ...selectChain, 705 + then: (resolve: (v: unknown) => void) => { resolve([samplePageRow()]); }, 706 + })) 707 + // Check children returns empty 708 + selectChain.where.mockImplementationOnce(() => ({ 709 + ...selectChain, 710 + then: (resolve: (v: unknown) => void) => { resolve([]); }, 711 + })) 712 + 713 + const response = await app.inject({ 714 + method: 'DELETE', 715 + url: `/api/admin/pages/${PAGE_ID_1}`, 716 + }) 717 + 718 + expect(response.statusCode).toBe(204) 719 + }) 720 + 721 + it('returns 404 for non-existent page', async () => { 722 + const response = await app.inject({ 723 + method: 'DELETE', 724 + url: '/api/admin/pages/page-nonexistent', 725 + }) 726 + 727 + expect(response.statusCode).toBe(404) 728 + }) 729 + 730 + it('returns 409 when page has children', async () => { 731 + // Find existing 732 + selectChain.where.mockImplementationOnce(() => ({ 733 + ...selectChain, 734 + then: (resolve: (v: unknown) => void) => { resolve([samplePageRow()]); }, 735 + })) 736 + // Check children returns a child 737 + selectChain.where.mockImplementationOnce(() => ({ 738 + ...selectChain, 739 + then: (resolve: (v: unknown) => void) => 740 + { resolve([samplePageRow({ id: PAGE_ID_2, parentId: PAGE_ID_1 })]); }, 741 + })) 742 + 743 + const response = await app.inject({ 744 + method: 'DELETE', 745 + url: `/api/admin/pages/${PAGE_ID_1}`, 746 + }) 747 + 748 + expect(response.statusCode).toBe(409) 749 + }) 750 + 751 + it('returns 401 without auth', async () => { 752 + const noAuthApp = await buildTestApp() 753 + const response = await noAuthApp.inject({ 754 + method: 'DELETE', 755 + url: `/api/admin/pages/${PAGE_ID_1}`, 756 + }) 757 + expect(response.statusCode).toBe(401) 758 + await noAuthApp.close() 759 + }) 760 + }) 761 + })
+88 -2
tests/unit/setup/service.test.ts
··· 307 307 308 308 await service.initialize({ did: TEST_DID, communityDid: TEST_COMMUNITY_DID }) 309 309 310 - // The insert should be called twice: once for community settings, once for onboarding field 311 - expect(mocks.insertFn).toHaveBeenCalledTimes(2) 310 + // The insert should be called three times: community settings, onboarding field, pages 311 + expect(mocks.insertFn).toHaveBeenCalledTimes(3) 312 312 313 313 // The second insert's values call should contain the platform age field 314 314 const secondValuesCall = mocks.valuesFn.mock.calls[1]?.[0] as Record<string, unknown> ··· 466 466 }) as Record<string, unknown>, 467 467 'Generating PLC DID during community setup' 468 468 ) 469 + }) 470 + }) 471 + 472 + // ========================================================================= 473 + // initialize (page seeding) 474 + // ========================================================================= 475 + 476 + describe('initialize() page seeding', () => { 477 + it('seeds default pages after admin promotion', async () => { 478 + mocks.returningFn.mockResolvedValueOnce([ 479 + { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 480 + ]) 481 + 482 + await service.initialize({ 483 + communityDid: TEST_COMMUNITY_DID, 484 + did: TEST_DID, 485 + }) 486 + 487 + // insert is called three times: community settings, onboarding field, pages 488 + expect(mocks.insertFn).toHaveBeenCalledTimes(3) 489 + }) 490 + 491 + it('seeds exactly 3 default pages with correct slugs', async () => { 492 + mocks.returningFn.mockResolvedValueOnce([ 493 + { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 494 + ]) 495 + 496 + // Capture the values passed to the third insert call (pages) 497 + let capturedPageValues: Array<{ slug: string; status: string; communityDid: string }> = [] 498 + mocks.valuesFn.mockImplementation((vals: unknown) => { 499 + if (Array.isArray(vals) && vals.length > 0 && 'slug' in (vals[0] as Record<string, unknown>)) { 500 + capturedPageValues = vals as typeof capturedPageValues 501 + } 502 + return { onConflictDoUpdate: mocks.onConflictDoUpdateFn, onConflictDoNothing: mocks.onConflictDoNothingFn } 503 + }) 504 + 505 + await service.initialize({ 506 + communityDid: TEST_COMMUNITY_DID, 507 + did: TEST_DID, 508 + }) 509 + 510 + expect(capturedPageValues).toHaveLength(3) 511 + const slugs = capturedPageValues.map((v) => v.slug) 512 + expect(slugs).toContain('terms-of-service') 513 + expect(slugs).toContain('privacy-policy') 514 + expect(slugs).toContain('cookie-policy') 515 + 516 + for (const page of capturedPageValues) { 517 + expect(page.status).toBe('published') 518 + expect(page.communityDid).toBe(TEST_COMMUNITY_DID) 519 + } 520 + }) 521 + 522 + it('does not seed pages when community is already initialized', async () => { 523 + mocks.returningFn.mockResolvedValueOnce([]) 524 + 525 + const result = await service.initialize({ 526 + communityDid: TEST_COMMUNITY_DID, 527 + did: TEST_DID, 528 + }) 529 + 530 + expect(result).toStrictEqual({ alreadyInitialized: true }) 531 + // Only 1 insert call (the upsert), no pages insert 532 + expect(mocks.insertFn).toHaveBeenCalledTimes(1) 533 + }) 534 + 535 + it('logs page seeding info', async () => { 536 + mocks.returningFn.mockResolvedValueOnce([ 537 + { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 538 + ]) 539 + 540 + await service.initialize({ 541 + communityDid: TEST_COMMUNITY_DID, 542 + did: TEST_DID, 543 + }) 544 + 545 + const infoFn = mockLogger.info as ReturnType<typeof vi.fn> 546 + const logCalls = infoFn.mock.calls as Array<[Record<string, unknown>, string]> 547 + const seedLog = logCalls.find( 548 + ([_ctx, msg]) => typeof msg === 'string' && msg.includes('Default pages seeded') 549 + ) 550 + expect(seedLog).toBeDefined() 551 + if (seedLog) { 552 + expect(seedLog[0]).toHaveProperty('communityDid', TEST_COMMUNITY_DID) 553 + expect(seedLog[0]).toHaveProperty('pageCount', 3) 554 + } 469 555 }) 470 556 }) 471 557
+332
tests/unit/validation/pages.test.ts
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { 3 + pageStatusSchema, 4 + createPageSchema, 5 + updatePageSchema, 6 + pageResponseSchema, 7 + pageTreeResponseSchema, 8 + } from '../../../src/validation/pages.js' 9 + 10 + // --------------------------------------------------------------------------- 11 + // pageStatusSchema 12 + // --------------------------------------------------------------------------- 13 + 14 + describe('pageStatusSchema', () => { 15 + it('accepts "draft"', () => { 16 + expect(pageStatusSchema.safeParse('draft').success).toBe(true) 17 + }) 18 + 19 + it('accepts "published"', () => { 20 + expect(pageStatusSchema.safeParse('published').success).toBe(true) 21 + }) 22 + 23 + it('rejects invalid status', () => { 24 + expect(pageStatusSchema.safeParse('archived').success).toBe(false) 25 + expect(pageStatusSchema.safeParse('').success).toBe(false) 26 + expect(pageStatusSchema.safeParse(42).success).toBe(false) 27 + }) 28 + }) 29 + 30 + // --------------------------------------------------------------------------- 31 + // createPageSchema 32 + // --------------------------------------------------------------------------- 33 + 34 + describe('createPageSchema', () => { 35 + const validInput = { 36 + title: 'Terms of Service', 37 + slug: 'terms-of-service', 38 + content: '## Hello world', 39 + status: 'published' as const, 40 + } 41 + 42 + it('accepts valid minimal input (title + slug)', () => { 43 + const result = createPageSchema.safeParse({ title: 'About', slug: 'about' }) 44 + expect(result.success).toBe(true) 45 + if (result.success) { 46 + expect(result.data.content).toBe('') 47 + expect(result.data.status).toBe('draft') 48 + } 49 + }) 50 + 51 + it('accepts valid full input', () => { 52 + const result = createPageSchema.safeParse({ 53 + ...validInput, 54 + metaDescription: 'Our terms of service.', 55 + parentId: 'page-abc', 56 + sortOrder: 5, 57 + }) 58 + expect(result.success).toBe(true) 59 + }) 60 + 61 + // Title validation 62 + it('rejects empty title', () => { 63 + expect(createPageSchema.safeParse({ ...validInput, title: '' }).success).toBe(false) 64 + }) 65 + 66 + it('rejects title over 200 chars', () => { 67 + expect(createPageSchema.safeParse({ ...validInput, title: 'x'.repeat(201) }).success).toBe( 68 + false 69 + ) 70 + }) 71 + 72 + it('accepts title at 200 chars', () => { 73 + expect(createPageSchema.safeParse({ ...validInput, title: 'x'.repeat(200) }).success).toBe( 74 + true 75 + ) 76 + }) 77 + 78 + // Slug validation 79 + it('rejects empty slug', () => { 80 + expect(createPageSchema.safeParse({ ...validInput, slug: '' }).success).toBe(false) 81 + }) 82 + 83 + it('rejects slug over 100 chars', () => { 84 + expect(createPageSchema.safeParse({ ...validInput, slug: 'a'.repeat(101) }).success).toBe( 85 + false 86 + ) 87 + }) 88 + 89 + it('rejects slug with uppercase', () => { 90 + expect(createPageSchema.safeParse({ ...validInput, slug: 'About-Us' }).success).toBe(false) 91 + }) 92 + 93 + it('rejects slug with spaces', () => { 94 + expect(createPageSchema.safeParse({ ...validInput, slug: 'about us' }).success).toBe(false) 95 + }) 96 + 97 + it('rejects slug with consecutive hyphens', () => { 98 + expect(createPageSchema.safeParse({ ...validInput, slug: 'about--us' }).success).toBe(false) 99 + }) 100 + 101 + it('rejects slug starting with a hyphen', () => { 102 + expect(createPageSchema.safeParse({ ...validInput, slug: '-about' }).success).toBe(false) 103 + }) 104 + 105 + it('rejects slug ending with a hyphen', () => { 106 + expect(createPageSchema.safeParse({ ...validInput, slug: 'about-' }).success).toBe(false) 107 + }) 108 + 109 + it('accepts valid slug with hyphens', () => { 110 + expect( 111 + createPageSchema.safeParse({ ...validInput, slug: 'terms-of-service' }).success 112 + ).toBe(true) 113 + }) 114 + 115 + // Reserved slugs 116 + it('rejects reserved slug "new"', () => { 117 + expect(createPageSchema.safeParse({ ...validInput, slug: 'new' }).success).toBe(false) 118 + }) 119 + 120 + it('rejects reserved slug "edit"', () => { 121 + expect(createPageSchema.safeParse({ ...validInput, slug: 'edit' }).success).toBe(false) 122 + }) 123 + 124 + it('rejects reserved slug "drafts"', () => { 125 + expect(createPageSchema.safeParse({ ...validInput, slug: 'drafts' }).success).toBe(false) 126 + }) 127 + 128 + // Content validation 129 + it('defaults content to empty string', () => { 130 + const result = createPageSchema.safeParse({ title: 'Hi', slug: 'hi' }) 131 + expect(result.success).toBe(true) 132 + if (result.success) { 133 + expect(result.data.content).toBe('') 134 + } 135 + }) 136 + 137 + it('rejects content over 100_000 chars', () => { 138 + expect( 139 + createPageSchema.safeParse({ ...validInput, content: 'x'.repeat(100_001) }).success 140 + ).toBe(false) 141 + }) 142 + 143 + it('accepts content at 100_000 chars', () => { 144 + expect( 145 + createPageSchema.safeParse({ ...validInput, content: 'x'.repeat(100_000) }).success 146 + ).toBe(true) 147 + }) 148 + 149 + // Status validation 150 + it('defaults status to draft', () => { 151 + const result = createPageSchema.safeParse({ title: 'Hi', slug: 'hi' }) 152 + expect(result.success).toBe(true) 153 + if (result.success) { 154 + expect(result.data.status).toBe('draft') 155 + } 156 + }) 157 + 158 + it('rejects invalid status', () => { 159 + expect(createPageSchema.safeParse({ ...validInput, status: 'archived' }).success).toBe(false) 160 + }) 161 + 162 + // metaDescription validation 163 + it('accepts null metaDescription', () => { 164 + expect( 165 + createPageSchema.safeParse({ ...validInput, metaDescription: null }).success 166 + ).toBe(true) 167 + }) 168 + 169 + it('rejects metaDescription over 320 chars', () => { 170 + expect( 171 + createPageSchema.safeParse({ ...validInput, metaDescription: 'x'.repeat(321) }).success 172 + ).toBe(false) 173 + }) 174 + 175 + it('accepts metaDescription at 320 chars', () => { 176 + expect( 177 + createPageSchema.safeParse({ ...validInput, metaDescription: 'x'.repeat(320) }).success 178 + ).toBe(true) 179 + }) 180 + 181 + // parentId 182 + it('accepts parentId as a string', () => { 183 + expect( 184 + createPageSchema.safeParse({ ...validInput, parentId: 'page-123' }).success 185 + ).toBe(true) 186 + }) 187 + 188 + it('accepts null parentId', () => { 189 + expect(createPageSchema.safeParse({ ...validInput, parentId: null }).success).toBe(true) 190 + }) 191 + 192 + // sortOrder 193 + it('rejects negative sortOrder', () => { 194 + expect(createPageSchema.safeParse({ ...validInput, sortOrder: -1 }).success).toBe(false) 195 + }) 196 + 197 + it('rejects non-integer sortOrder', () => { 198 + expect(createPageSchema.safeParse({ ...validInput, sortOrder: 1.5 }).success).toBe(false) 199 + }) 200 + 201 + it('accepts sortOrder of 0', () => { 202 + expect(createPageSchema.safeParse({ ...validInput, sortOrder: 0 }).success).toBe(true) 203 + }) 204 + }) 205 + 206 + // --------------------------------------------------------------------------- 207 + // updatePageSchema 208 + // --------------------------------------------------------------------------- 209 + 210 + describe('updatePageSchema', () => { 211 + it('accepts empty object (all fields optional)', () => { 212 + expect(updatePageSchema.safeParse({}).success).toBe(true) 213 + }) 214 + 215 + it('accepts partial updates', () => { 216 + expect(updatePageSchema.safeParse({ title: 'New Title' }).success).toBe(true) 217 + expect(updatePageSchema.safeParse({ status: 'published' }).success).toBe(true) 218 + expect(updatePageSchema.safeParse({ content: 'New content' }).success).toBe(true) 219 + }) 220 + 221 + it('rejects empty title', () => { 222 + expect(updatePageSchema.safeParse({ title: '' }).success).toBe(false) 223 + }) 224 + 225 + it('rejects empty slug', () => { 226 + expect(updatePageSchema.safeParse({ slug: '' }).success).toBe(false) 227 + }) 228 + 229 + it('accepts nullable parentId', () => { 230 + expect(updatePageSchema.safeParse({ parentId: null }).success).toBe(true) 231 + }) 232 + 233 + it('accepts nullable metaDescription', () => { 234 + expect(updatePageSchema.safeParse({ metaDescription: null }).success).toBe(true) 235 + }) 236 + 237 + it('rejects reserved slug "new"', () => { 238 + expect(updatePageSchema.safeParse({ slug: 'new' }).success).toBe(false) 239 + }) 240 + 241 + it('rejects reserved slug "edit"', () => { 242 + expect(updatePageSchema.safeParse({ slug: 'edit' }).success).toBe(false) 243 + }) 244 + 245 + it('rejects reserved slug "drafts"', () => { 246 + expect(updatePageSchema.safeParse({ slug: 'drafts' }).success).toBe(false) 247 + }) 248 + }) 249 + 250 + // --------------------------------------------------------------------------- 251 + // pageResponseSchema 252 + // --------------------------------------------------------------------------- 253 + 254 + describe('pageResponseSchema', () => { 255 + it('accepts a valid page response', () => { 256 + const result = pageResponseSchema.safeParse({ 257 + id: 'page-001', 258 + slug: 'about', 259 + title: 'About Us', 260 + content: '## About', 261 + status: 'published', 262 + metaDescription: null, 263 + parentId: null, 264 + sortOrder: 0, 265 + communityDid: 'did:plc:community123', 266 + createdAt: '2026-01-01T00:00:00.000Z', 267 + updatedAt: '2026-01-01T00:00:00.000Z', 268 + }) 269 + expect(result.success).toBe(true) 270 + }) 271 + 272 + it('rejects missing required fields', () => { 273 + expect(pageResponseSchema.safeParse({ id: 'page-001' }).success).toBe(false) 274 + }) 275 + }) 276 + 277 + // --------------------------------------------------------------------------- 278 + // pageTreeResponseSchema 279 + // --------------------------------------------------------------------------- 280 + 281 + describe('pageTreeResponseSchema', () => { 282 + it('accepts a page with empty children', () => { 283 + const result = pageTreeResponseSchema.safeParse({ 284 + id: 'page-001', 285 + slug: 'about', 286 + title: 'About Us', 287 + content: '## About', 288 + status: 'published', 289 + metaDescription: null, 290 + parentId: null, 291 + sortOrder: 0, 292 + communityDid: 'did:plc:community123', 293 + createdAt: '2026-01-01T00:00:00.000Z', 294 + updatedAt: '2026-01-01T00:00:00.000Z', 295 + children: [], 296 + }) 297 + expect(result.success).toBe(true) 298 + }) 299 + 300 + it('accepts nested children', () => { 301 + const result = pageTreeResponseSchema.safeParse({ 302 + id: 'page-001', 303 + slug: 'legal', 304 + title: 'Legal', 305 + content: '', 306 + status: 'published', 307 + metaDescription: null, 308 + parentId: null, 309 + sortOrder: 0, 310 + communityDid: 'did:plc:community123', 311 + createdAt: '2026-01-01T00:00:00.000Z', 312 + updatedAt: '2026-01-01T00:00:00.000Z', 313 + children: [ 314 + { 315 + id: 'page-002', 316 + slug: 'terms', 317 + title: 'Terms', 318 + content: '## Terms', 319 + status: 'published', 320 + metaDescription: null, 321 + parentId: 'page-001', 322 + sortOrder: 0, 323 + communityDid: 'did:plc:community123', 324 + createdAt: '2026-01-01T00:00:00.000Z', 325 + updatedAt: '2026-01-01T00:00:00.000Z', 326 + children: [], 327 + }, 328 + ], 329 + }) 330 + expect(result.success).toBe(true) 331 + }) 332 + })