WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

docs: design for boards hierarchy restructuring (ATB-23)

- Restructure from 2-level to 3-level traditional BB hierarchy
- Categories become groupings (non-postable)
- Boards become postable areas (new concept)
- Posts link to both boards and forums
- Includes lexicon, schema, API, and indexer changes
- No migration needed (no production data)

+3049
+2492
docs/plans/2026-02-13-atb-23-boards-hierarchy-implementation.md
··· 1 + # ATB-23 Boards Hierarchy Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. 4 + 5 + **Goal:** Restructure atBB from 2-level to 3-level traditional BB forum hierarchy by introducing boards as postable areas, with categories becoming groupings. 6 + 7 + **Architecture:** Categories become non-postable groupings, new boards table holds postable areas. Posts link to both boards (primary) and forums (redundant for client flexibility). Indexer extracts board references from AT Proto records. API provides full hierarchy navigation: categories → boards → topics. 8 + 9 + **Tech Stack:** TypeScript, Drizzle ORM, Hono, AT Protocol Lexicons, Vitest, postgres.js 10 + 11 + **Design Document:** `docs/plans/2026-02-13-boards-hierarchy-design.md` 12 + 13 + --- 14 + 15 + ## Phase 1: Lexicon & Schema 16 + 17 + ### Task 1: Create Board Lexicon 18 + 19 + **Files:** 20 + - Create: `packages/lexicon/lexicons/space/atbb/forum/board.yaml` 21 + - Reference: `packages/lexicon/lexicons/space/atbb/forum/category.yaml` (pattern to follow) 22 + 23 + **Step 1: Create board.yaml lexicon** 24 + 25 + Create `packages/lexicon/lexicons/space/atbb/forum/board.yaml`: 26 + 27 + ```yaml 28 + # yaml-language-server: $schema=https://boat.kelinci.net/lexicon-document.json 29 + --- 30 + lexicon: 1 31 + id: space.atbb.forum.board 32 + defs: 33 + main: 34 + type: record 35 + description: >- 36 + A board (subforum) within a category. 37 + Owned by the Forum DID. 38 + key: tid 39 + record: 40 + type: object 41 + required: 42 + - name 43 + - category 44 + - createdAt 45 + properties: 46 + name: 47 + type: string 48 + maxLength: 300 49 + maxGraphemes: 100 50 + description: >- 51 + Display name of the board. 52 + description: 53 + type: string 54 + maxLength: 3000 55 + maxGraphemes: 300 56 + description: >- 57 + A short description for the board. 58 + slug: 59 + type: string 60 + maxLength: 100 61 + description: >- 62 + URL-friendly identifier for the board. 63 + Must be lowercase alphanumeric with hyphens. 64 + sortOrder: 65 + type: integer 66 + minimum: 0 67 + description: >- 68 + Numeric sort position. Lower values appear first. 69 + category: 70 + type: ref 71 + ref: "#categoryRef" 72 + createdAt: 73 + type: string 74 + format: datetime 75 + description: >- 76 + Timestamp when this board was created. 77 + categoryRef: 78 + type: object 79 + required: 80 + - category 81 + properties: 82 + category: 83 + type: ref 84 + ref: com.atproto.repo.strongRef 85 + ``` 86 + 87 + **Step 2: Build lexicon to generate JSON** 88 + 89 + ```bash 90 + pnpm --filter @atbb/lexicon build:json 91 + ``` 92 + 93 + Expected: Creates `packages/lexicon/dist/lexicons/space/atbb/forum/board.json` 94 + 95 + **Step 3: Generate TypeScript types** 96 + 97 + ```bash 98 + pnpm --filter @atbb/lexicon build:types 99 + ``` 100 + 101 + Expected: Updates `packages/lexicon/src/types.ts` with `SpaceAtbbForumBoard` namespace 102 + 103 + **Step 4: Run full lexicon build** 104 + 105 + ```bash 106 + pnpm --filter @atbb/lexicon build 107 + ``` 108 + 109 + Expected: All steps pass, types compile successfully 110 + 111 + **Step 5: Verify types are exported** 112 + 113 + Check `packages/lexicon/src/types.ts` contains: 114 + 115 + ```typescript 116 + export * as SpaceAtbbForumBoard from './types/space/atbb/forum/board'; 117 + ``` 118 + 119 + **Step 6: Commit** 120 + 121 + ```bash 122 + git add packages/lexicon/lexicons/space/atbb/forum/board.yaml 123 + git add packages/lexicon/dist/ 124 + git add packages/lexicon/src/types.ts 125 + git commit -m "feat(lexicon): add space.atbb.forum.board lexicon 126 + 127 + - Board record type with category reference 128 + - Owned by Forum DID, key: tid 129 + - Required: name, category, createdAt 130 + - Optional: description, slug, sortOrder" 131 + ``` 132 + 133 + --- 134 + 135 + ### Task 2: Update Post Lexicon with Board Reference 136 + 137 + **Files:** 138 + - Modify: `packages/lexicon/lexicons/space/atbb/post.yaml:24-29` 139 + 140 + **Step 1: Add boardRef to post.yaml** 141 + 142 + Add `board` property and `boardRef` definition to `packages/lexicon/lexicons/space/atbb/post.yaml`: 143 + 144 + ```yaml 145 + properties: 146 + text: 147 + type: string 148 + maxLength: 3000 149 + maxGraphemes: 300 150 + description: >- 151 + The primary post content. 152 + May be an empty string, if there are embeds. 153 + forum: 154 + type: ref 155 + ref: "#forumRef" 156 + board: # NEW 157 + type: ref 158 + ref: "#boardRef" 159 + reply: 160 + type: ref 161 + ref: "#replyRef" 162 + createdAt: 163 + type: string 164 + format: datetime 165 + description: >- 166 + Client-declared timestamp when this post was originally created. 167 + ``` 168 + 169 + And add the `boardRef` definition after `forumRef`: 170 + 171 + ```yaml 172 + boardRef: 173 + type: object 174 + required: 175 + - board 176 + properties: 177 + board: 178 + type: ref 179 + ref: com.atproto.repo.strongRef 180 + ``` 181 + 182 + **Step 2: Rebuild lexicon** 183 + 184 + ```bash 185 + pnpm --filter @atbb/lexicon build 186 + ``` 187 + 188 + Expected: TypeScript types updated with `board` field in Post record 189 + 190 + **Step 3: Verify types updated** 191 + 192 + Check `packages/lexicon/src/types/space/atbb/post.ts` has: 193 + 194 + ```typescript 195 + export interface Record { 196 + text: string 197 + forum?: ForumRef 198 + board?: BoardRef // NEW 199 + reply?: ReplyRef 200 + createdAt: string 201 + [k: string]: unknown 202 + } 203 + ``` 204 + 205 + **Step 4: Commit** 206 + 207 + ```bash 208 + git add packages/lexicon/lexicons/space/atbb/post.yaml 209 + git add packages/lexicon/dist/ 210 + git add packages/lexicon/src/ 211 + git commit -m "feat(lexicon): add board reference to post lexicon 212 + 213 + - Posts can now reference boards via boardRef 214 + - Optional field for backwards compatibility 215 + - Keeps forum reference for client flexibility" 216 + ``` 217 + 218 + --- 219 + 220 + ### Task 3: Create Database Migration for Boards Table 221 + 222 + **Files:** 223 + - Create: `packages/db/drizzle/0003_add_boards_table.sql` 224 + - Reference: `packages/db/drizzle/0002_add_firehose_cursor.sql` (migration pattern) 225 + 226 + **Step 1: Create migration file** 227 + 228 + Create `packages/db/drizzle/0003_add_boards_table.sql`: 229 + 230 + ```sql 231 + -- Migration: Create boards table 232 + -- Date: 2026-02-13 233 + -- Description: Add boards table for 3-level forum hierarchy 234 + 235 + CREATE TABLE boards ( 236 + id BIGSERIAL PRIMARY KEY, 237 + did TEXT NOT NULL, 238 + rkey TEXT NOT NULL, 239 + cid TEXT NOT NULL, 240 + name TEXT NOT NULL, 241 + description TEXT, 242 + slug TEXT, 243 + sort_order INTEGER, 244 + category_id BIGINT REFERENCES categories(id), 245 + category_uri TEXT NOT NULL, 246 + created_at TIMESTAMPTZ NOT NULL, 247 + indexed_at TIMESTAMPTZ NOT NULL 248 + ); 249 + 250 + CREATE UNIQUE INDEX boards_did_rkey_idx ON boards(did, rkey); 251 + CREATE INDEX boards_category_id_idx ON boards(category_id); 252 + ``` 253 + 254 + **Step 2: Update schema.ts with boards table** 255 + 256 + Add to `packages/db/src/schema.ts` after categories table: 257 + 258 + ```typescript 259 + // ── boards ────────────────────────────────────────────── 260 + // Board (subforum) definitions within categories, owned by Forum DID. 261 + export const boards = pgTable( 262 + "boards", 263 + { 264 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 265 + did: text("did").notNull(), 266 + rkey: text("rkey").notNull(), 267 + cid: text("cid").notNull(), 268 + name: text("name").notNull(), 269 + description: text("description"), 270 + slug: text("slug"), 271 + sortOrder: integer("sort_order"), 272 + categoryId: bigint("category_id", { mode: "bigint" }).references( 273 + () => categories.id 274 + ), 275 + categoryUri: text("category_uri").notNull(), 276 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 277 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 278 + }, 279 + (table) => [ 280 + uniqueIndex("boards_did_rkey_idx").on(table.did, table.rkey), 281 + index("boards_category_id_idx").on(table.categoryId), 282 + ] 283 + ); 284 + ``` 285 + 286 + **Step 3: Export boards from schema** 287 + 288 + Update exports in `packages/db/src/schema.ts`: 289 + 290 + ```typescript 291 + export { 292 + forums, 293 + categories, 294 + boards, // NEW 295 + users, 296 + memberships, 297 + posts, 298 + modActions, 299 + firehoseCursor, 300 + }; 301 + ``` 302 + 303 + **Step 4: Run migration** 304 + 305 + ```bash 306 + pnpm --filter @atbb/appview db:migrate 307 + ``` 308 + 309 + Expected: Migration applies successfully, boards table created 310 + 311 + **Step 5: Verify table exists** 312 + 313 + ```bash 314 + psql $DATABASE_URL -c "\d boards" 315 + ``` 316 + 317 + Expected: Shows table structure with all columns and indexes 318 + 319 + **Step 6: Commit** 320 + 321 + ```bash 322 + git add packages/db/drizzle/0003_add_boards_table.sql 323 + git add packages/db/src/schema.ts 324 + git commit -m "feat(db): add boards table for 3-level hierarchy 325 + 326 + - Boards belong to categories (categoryId FK) 327 + - Store category URI for out-of-order indexing 328 + - Unique index on (did, rkey) for AT Proto records 329 + - Index on category_id for efficient filtering" 330 + ``` 331 + 332 + --- 333 + 334 + ### Task 4: Add Board Columns to Posts Table 335 + 336 + **Files:** 337 + - Create: `packages/db/drizzle/0004_add_board_columns_to_posts.sql` 338 + - Modify: `packages/db/src/schema.ts:93-121` 339 + 340 + **Step 1: Create migration file** 341 + 342 + Create `packages/db/drizzle/0004_add_board_columns_to_posts.sql`: 343 + 344 + ```sql 345 + -- Migration: Add board columns to posts 346 + -- Date: 2026-02-13 347 + -- Description: Link posts to boards for topic filtering 348 + 349 + ALTER TABLE posts ADD COLUMN board_uri TEXT; 350 + ALTER TABLE posts ADD COLUMN board_id BIGINT REFERENCES boards(id); 351 + 352 + CREATE INDEX posts_board_id_idx ON posts(board_id); 353 + CREATE INDEX posts_board_uri_idx ON posts(board_uri); 354 + ``` 355 + 356 + **Step 2: Update posts schema** 357 + 358 + Update `packages/db/src/schema.ts` posts table definition: 359 + 360 + ```typescript 361 + export const posts = pgTable( 362 + "posts", 363 + { 364 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 365 + did: text("did") 366 + .notNull() 367 + .references(() => users.did), 368 + rkey: text("rkey").notNull(), 369 + cid: text("cid").notNull(), 370 + text: text("text").notNull(), 371 + forumUri: text("forum_uri"), 372 + boardUri: text("board_uri"), // NEW 373 + boardId: bigint("board_id", { mode: "bigint" }).references( // NEW 374 + () => boards.id 375 + ), 376 + rootPostId: bigint("root_post_id", { mode: "bigint" }).references( 377 + (): any => posts.id 378 + ), 379 + parentPostId: bigint("parent_post_id", { mode: "bigint" }).references( 380 + (): any => posts.id 381 + ), 382 + rootUri: text("root_uri"), 383 + parentUri: text("parent_uri"), 384 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 385 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 386 + deleted: boolean("deleted").notNull().default(false), 387 + }, 388 + (table) => [ 389 + uniqueIndex("posts_did_rkey_idx").on(table.did, table.rkey), 390 + index("posts_forum_uri_idx").on(table.forumUri), 391 + index("posts_board_id_idx").on(table.boardId), // NEW 392 + index("posts_board_uri_idx").on(table.boardUri), // NEW 393 + index("posts_root_post_id_idx").on(table.rootPostId), 394 + ] 395 + ); 396 + ``` 397 + 398 + **Step 3: Run migration** 399 + 400 + ```bash 401 + pnpm --filter @atbb/appview db:migrate 402 + ``` 403 + 404 + Expected: Migration applies, board_uri and board_id columns added 405 + 406 + **Step 4: Verify columns exist** 407 + 408 + ```bash 409 + psql $DATABASE_URL -c "\d posts" 410 + ``` 411 + 412 + Expected: Shows board_uri and board_id columns with indexes 413 + 414 + **Step 5: Commit** 415 + 416 + ```bash 417 + git add packages/db/drizzle/0004_add_board_columns_to_posts.sql 418 + git add packages/db/src/schema.ts 419 + git commit -m "feat(db): add board columns to posts table 420 + 421 + - Add board_uri and board_id columns (nullable) 422 + - Indexes for efficient board filtering 423 + - Keeps forum_uri for client flexibility" 424 + ``` 425 + 426 + --- 427 + 428 + ## Phase 2: Indexer 429 + 430 + ### Task 5: Add Board Helper Methods to Indexer 431 + 432 + **Files:** 433 + - Modify: `apps/appview/src/lib/indexer.ts:240-end` 434 + 435 + **Step 1: Write test for getBoardIdByUri** 436 + 437 + Create test in `apps/appview/src/lib/__tests__/indexer.test.ts`: 438 + 439 + ```typescript 440 + import { describe, it, expect, beforeEach } from "vitest"; 441 + import { Indexer } from "../indexer.js"; 442 + import { createTestContext, type TestContext } from "./test-context.js"; 443 + import { boards, categories, forums } from "@atbb/db"; 444 + 445 + describe("Indexer - Board Helpers", () => { 446 + let ctx: TestContext; 447 + let indexer: Indexer; 448 + 449 + beforeEach(async () => { 450 + ctx = await createTestContext(); 451 + indexer = new Indexer(ctx.db); 452 + 453 + // Insert test forum 454 + await ctx.db.insert(forums).values({ 455 + did: "did:plc:test-forum", 456 + rkey: "self", 457 + cid: "bafytest", 458 + name: "Test Forum", 459 + description: "Test", 460 + indexedAt: new Date(), 461 + }); 462 + 463 + // Insert test category 464 + const [category] = await ctx.db.insert(categories).values({ 465 + did: "did:plc:test-forum", 466 + rkey: "cat1", 467 + cid: "bafycat", 468 + name: "Test Category", 469 + forumId: 1n, 470 + createdAt: new Date(), 471 + indexedAt: new Date(), 472 + }).returning(); 473 + 474 + // Insert test board 475 + await ctx.db.insert(boards).values({ 476 + did: "did:plc:test-forum", 477 + rkey: "board1", 478 + cid: "bafyboard", 479 + name: "Test Board", 480 + categoryId: category.id, 481 + categoryUri: `at://did:plc:test-forum/space.atbb.forum.category/cat1`, 482 + createdAt: new Date(), 483 + indexedAt: new Date(), 484 + }); 485 + }); 486 + 487 + it("getBoardIdByUri returns board ID for valid URI", async () => { 488 + const uri = "at://did:plc:test-forum/space.atbb.forum.board/board1"; 489 + const boardId = await indexer["getBoardIdByUri"](uri, ctx.db); 490 + expect(boardId).toBe(1n); 491 + }); 492 + 493 + it("getBoardIdByUri returns null for non-existent board", async () => { 494 + const uri = "at://did:plc:test-forum/space.atbb.forum.board/nonexistent"; 495 + const boardId = await indexer["getBoardIdByUri"](uri, ctx.db); 496 + expect(boardId).toBeNull(); 497 + }); 498 + }); 499 + ``` 500 + 501 + **Step 2: Run test to verify it fails** 502 + 503 + ```bash 504 + pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts 505 + ``` 506 + 507 + Expected: FAIL - "Property 'getBoardIdByUri' does not exist on type 'Indexer'" 508 + 509 + **Step 3: Add getBoardIdByUri method** 510 + 511 + Add to `apps/appview/src/lib/indexer.ts` after existing helper methods: 512 + 513 + ```typescript 514 + /** 515 + * Look up board ID by AT URI (at://did/collection/rkey) 516 + */ 517 + private async getBoardIdByUri( 518 + uri: string, 519 + tx: DbOrTransaction 520 + ): Promise<bigint | null> { 521 + const { did, rkey } = parseAtUri(uri); 522 + const [result] = await tx 523 + .select({ id: boards.id }) 524 + .from(boards) 525 + .where(and(eq(boards.did, did), eq(boards.rkey, rkey))) 526 + .limit(1); 527 + return result?.id ?? null; 528 + } 529 + 530 + /** 531 + * Look up category ID by AT URI (at://did/collection/rkey) 532 + */ 533 + private async getCategoryIdByUri( 534 + uri: string, 535 + tx: DbOrTransaction 536 + ): Promise<bigint | null> { 537 + const { did, rkey } = parseAtUri(uri); 538 + const [result] = await tx 539 + .select({ id: categories.id }) 540 + .from(categories) 541 + .where(and(eq(categories.did, did), eq(categories.rkey, rkey))) 542 + .limit(1); 543 + return result?.id ?? null; 544 + } 545 + ``` 546 + 547 + **Step 4: Add boards import** 548 + 549 + Update imports in `apps/appview/src/lib/indexer.ts`: 550 + 551 + ```typescript 552 + import { 553 + posts, 554 + forums, 555 + categories, 556 + boards, // NEW 557 + users, 558 + memberships, 559 + modActions, 560 + } from "@atbb/db"; 561 + ``` 562 + 563 + **Step 5: Run test to verify it passes** 564 + 565 + ```bash 566 + pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts 567 + ``` 568 + 569 + Expected: PASS - Both tests pass 570 + 571 + **Step 6: Commit** 572 + 573 + ```bash 574 + git add apps/appview/src/lib/indexer.ts 575 + git add apps/appview/src/lib/__tests__/indexer.test.ts 576 + git commit -m "feat(indexer): add helper methods for board/category URI lookup 577 + 578 + - getBoardIdByUri: resolve board AT URI to database ID 579 + - getCategoryIdByUri: resolve category AT URI to database ID 580 + - Both return null for non-existent records" 581 + ``` 582 + 583 + --- 584 + 585 + ### Task 6: Add Board Config to Indexer 586 + 587 + **Files:** 588 + - Modify: `apps/appview/src/lib/indexer.ts:108-127` 589 + - Create test: `apps/appview/src/lib/__tests__/indexer-boards.test.ts` 590 + 591 + **Step 1: Write board indexing test** 592 + 593 + Create `apps/appview/src/lib/__tests__/indexer-boards.test.ts`: 594 + 595 + ```typescript 596 + import { describe, it, expect, beforeEach } from "vitest"; 597 + import { Indexer } from "../indexer.js"; 598 + import { createTestContext, type TestContext } from "./test-context.js"; 599 + import { boards, categories, forums } from "@atbb/db"; 600 + import { eq } from "drizzle-orm"; 601 + import type { CommitCreateEvent } from "@skyware/jetstream"; 602 + 603 + describe("Indexer - Board Handlers", () => { 604 + let ctx: TestContext; 605 + let indexer: Indexer; 606 + 607 + beforeEach(async () => { 608 + ctx = await createTestContext(); 609 + indexer = new Indexer(ctx.db); 610 + 611 + // Insert test forum 612 + await ctx.db.insert(forums).values({ 613 + did: "did:plc:test-forum", 614 + rkey: "self", 615 + cid: "bafytest", 616 + name: "Test Forum", 617 + indexedAt: new Date(), 618 + }); 619 + 620 + // Insert test category 621 + await ctx.db.insert(categories).values({ 622 + did: "did:plc:test-forum", 623 + rkey: "cat1", 624 + cid: "bafycat", 625 + name: "Test Category", 626 + forumId: 1n, 627 + createdAt: new Date(), 628 + indexedAt: new Date(), 629 + }); 630 + }); 631 + 632 + it("handleBoardCreate indexes board record", async () => { 633 + const event: CommitCreateEvent<"space.atbb.forum.board"> = { 634 + kind: "commit", 635 + commit: { 636 + rev: "abc123", 637 + operation: "create", 638 + collection: "space.atbb.forum.board", 639 + rkey: "board1", 640 + record: { 641 + $type: "space.atbb.forum.board", 642 + name: "General Discussion", 643 + description: "Talk about anything", 644 + slug: "general", 645 + sortOrder: 1, 646 + category: { 647 + category: { 648 + uri: "at://did:plc:test-forum/space.atbb.forum.category/cat1", 649 + cid: "bafycat", 650 + }, 651 + }, 652 + createdAt: "2026-02-13T00:00:00Z", 653 + }, 654 + cid: "bafyboard", 655 + }, 656 + did: "did:plc:test-forum", 657 + time_us: 1000000, 658 + }; 659 + 660 + await indexer.handleBoardCreate(event); 661 + 662 + const [board] = await ctx.db 663 + .select() 664 + .from(boards) 665 + .where(eq(boards.rkey, "board1")); 666 + 667 + expect(board).toBeDefined(); 668 + expect(board.name).toBe("General Discussion"); 669 + expect(board.slug).toBe("general"); 670 + expect(board.sortOrder).toBe(1); 671 + expect(board.categoryId).toBe(1n); 672 + expect(board.categoryUri).toBe( 673 + "at://did:plc:test-forum/space.atbb.forum.category/cat1" 674 + ); 675 + }); 676 + 677 + it("handleBoardCreate skips board when category not found", async () => { 678 + const event: CommitCreateEvent<"space.atbb.forum.board"> = { 679 + kind: "commit", 680 + commit: { 681 + rev: "abc123", 682 + operation: "create", 683 + collection: "space.atbb.forum.board", 684 + rkey: "board2", 685 + record: { 686 + $type: "space.atbb.forum.board", 687 + name: "Orphan Board", 688 + category: { 689 + category: { 690 + uri: "at://did:plc:test-forum/space.atbb.forum.category/nonexistent", 691 + cid: "bafynonexistent", 692 + }, 693 + }, 694 + createdAt: "2026-02-13T00:00:00Z", 695 + }, 696 + cid: "bafyboard2", 697 + }, 698 + did: "did:plc:test-forum", 699 + time_us: 1000000, 700 + }; 701 + 702 + await indexer.handleBoardCreate(event); 703 + 704 + const results = await ctx.db.select().from(boards); 705 + expect(results).toHaveLength(0); 706 + }); 707 + }); 708 + ``` 709 + 710 + **Step 2: Run test to verify it fails** 711 + 712 + ```bash 713 + pnpm --filter @atbb/appview test src/lib/__tests__/indexer-boards.test.ts 714 + ``` 715 + 716 + Expected: FAIL - "Property 'handleBoardCreate' does not exist" 717 + 718 + **Step 3: Add boardConfig to Indexer** 719 + 720 + Add after `categoryConfig` in `apps/appview/src/lib/indexer.ts`: 721 + 722 + ```typescript 723 + private boardConfig: CollectionConfig<Board.Record> = { 724 + name: "Board", 725 + table: boards, 726 + deleteStrategy: "hard", 727 + toInsertValues: async (event, record, tx) => { 728 + // Boards are owned by Forum DID 729 + const categoryId = await this.getCategoryIdByUri( 730 + record.category.category.uri, 731 + tx 732 + ); 733 + 734 + if (!categoryId) { 735 + console.warn( 736 + `[CREATE] Board: Category not found for URI ${record.category.category.uri}` 737 + ); 738 + return null; 739 + } 740 + 741 + return { 742 + did: event.did, 743 + rkey: event.commit.rkey, 744 + cid: event.commit.cid, 745 + name: record.name, 746 + description: record.description ?? null, 747 + slug: record.slug ?? null, 748 + sortOrder: record.sortOrder ?? null, 749 + categoryId, 750 + categoryUri: record.category.category.uri, 751 + createdAt: new Date(record.createdAt), 752 + indexedAt: new Date(), 753 + }; 754 + }, 755 + toUpdateValues: async (event, record, tx) => { 756 + const categoryId = await this.getCategoryIdByUri( 757 + record.category.category.uri, 758 + tx 759 + ); 760 + 761 + return { 762 + cid: event.commit.cid, 763 + name: record.name, 764 + description: record.description ?? null, 765 + slug: record.slug ?? null, 766 + sortOrder: record.sortOrder ?? null, 767 + categoryId, 768 + categoryUri: record.category.category.uri, 769 + indexedAt: new Date(), 770 + }; 771 + }, 772 + }; 773 + ``` 774 + 775 + **Step 4: Add Board type import** 776 + 777 + Update imports in `apps/appview/src/lib/indexer.ts`: 778 + 779 + ```typescript 780 + import { 781 + SpaceAtbbPost as Post, 782 + SpaceAtbbForumForum as Forum, 783 + SpaceAtbbForumCategory as Category, 784 + SpaceAtbbForumBoard as Board, // NEW 785 + SpaceAtbbMembership as Membership, 786 + SpaceAtbbModAction as ModAction, 787 + } from "@atbb/lexicon"; 788 + ``` 789 + 790 + **Step 5: Generate board handler methods** 791 + 792 + Add generic handler methods at the end of Indexer class: 793 + 794 + ```typescript 795 + async handleBoardCreate(event: CommitCreateEvent<"space.atbb.forum.board">) { 796 + await this.handleCreate(event, this.boardConfig); 797 + } 798 + 799 + async handleBoardUpdate(event: CommitUpdateEvent<"space.atbb.forum.board">) { 800 + await this.handleUpdate(event, this.boardConfig); 801 + } 802 + 803 + async handleBoardDelete(event: CommitDeleteEvent<"space.atbb.forum.board">) { 804 + await this.handleDelete(event, this.boardConfig); 805 + } 806 + ``` 807 + 808 + **Step 6: Run test to verify it passes** 809 + 810 + ```bash 811 + pnpm --filter @atbb/appview test src/lib/__tests__/indexer-boards.test.ts 812 + ``` 813 + 814 + Expected: PASS 815 + 816 + **Step 7: Commit** 817 + 818 + ```bash 819 + git add apps/appview/src/lib/indexer.ts 820 + git add apps/appview/src/lib/__tests__/indexer-boards.test.ts 821 + git commit -m "feat(indexer): add board indexing handlers 822 + 823 + - handleBoardCreate/Update/Delete methods 824 + - Resolves category URI to ID before insert 825 + - Skips insert if category not found (logs warning) 826 + - Tests verify indexing and orphan handling" 827 + ``` 828 + 829 + --- 830 + 831 + ### Task 7: Update Post Indexer to Extract boardUri 832 + 833 + **Files:** 834 + - Modify: `apps/appview/src/lib/indexer.ts:76-106` 835 + 836 + **Step 1: Write test for post with board** 837 + 838 + Add to existing `apps/appview/src/lib/__tests__/firehose.test.ts`: 839 + 840 + ```typescript 841 + it("indexes post with board reference", async () => { 842 + // Setup: Create forum, category, and board 843 + await ctx.db.insert(forums).values({ 844 + did: "did:plc:forum", 845 + rkey: "self", 846 + cid: "bafyforum", 847 + name: "Test Forum", 848 + indexedAt: new Date(), 849 + }); 850 + 851 + const [category] = await ctx.db.insert(categories).values({ 852 + did: "did:plc:forum", 853 + rkey: "cat1", 854 + cid: "bafycat", 855 + name: "Category", 856 + forumId: 1n, 857 + createdAt: new Date(), 858 + indexedAt: new Date(), 859 + }).returning(); 860 + 861 + await ctx.db.insert(boards).values({ 862 + did: "did:plc:forum", 863 + rkey: "board1", 864 + cid: "bafyboard", 865 + name: "Board", 866 + categoryId: category.id, 867 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/cat1", 868 + createdAt: new Date(), 869 + indexedAt: new Date(), 870 + }); 871 + 872 + const event: CommitCreateEvent<"space.atbb.post"> = { 873 + kind: "commit", 874 + commit: { 875 + rev: "abc", 876 + operation: "create", 877 + collection: "space.atbb.post", 878 + rkey: "post1", 879 + record: { 880 + $type: "space.atbb.post", 881 + text: "Test post", 882 + forum: { 883 + forum: { 884 + uri: "at://did:plc:forum/space.atbb.forum.forum/self", 885 + cid: "bafyforum", 886 + }, 887 + }, 888 + board: { 889 + board: { 890 + uri: "at://did:plc:forum/space.atbb.forum.board/board1", 891 + cid: "bafyboard", 892 + }, 893 + }, 894 + createdAt: "2026-02-13T00:00:00Z", 895 + }, 896 + cid: "bafypost", 897 + }, 898 + did: "did:plc:user", 899 + time_us: 1000000, 900 + }; 901 + 902 + await indexer.handlePostCreate(event); 903 + 904 + const [post] = await ctx.db 905 + .select() 906 + .from(posts) 907 + .where(eq(posts.rkey, "post1")); 908 + 909 + expect(post.boardUri).toBe("at://did:plc:forum/space.atbb.forum.board/board1"); 910 + expect(post.boardId).toBe(1n); 911 + }); 912 + ``` 913 + 914 + **Step 2: Run test to verify it fails** 915 + 916 + ```bash 917 + pnpm --filter @atbb/appview test src/lib/__tests__/firehose.test.ts -t "indexes post with board" 918 + ``` 919 + 920 + Expected: FAIL - boardUri and boardId are undefined 921 + 922 + **Step 3: Update postConfig toInsertValues** 923 + 924 + Update `toInsertValues` in `apps/appview/src/lib/indexer.ts`: 925 + 926 + ```typescript 927 + toInsertValues: async (event, record, tx) => { 928 + // Look up board for the post 929 + let boardId: bigint | null = null; 930 + if (record.board) { 931 + boardId = await this.getBoardIdByUri(record.board.board.uri, tx); 932 + } 933 + 934 + // Look up parent/root for replies 935 + let rootId: bigint | null = null; 936 + let parentId: bigint | null = null; 937 + 938 + if (Post.isReplyRef(record.reply)) { 939 + rootId = await this.getPostIdByUri(record.reply.root.uri, tx); 940 + parentId = await this.getPostIdByUri(record.reply.parent.uri, tx); 941 + } 942 + 943 + return { 944 + did: event.did, 945 + rkey: event.commit.rkey, 946 + cid: event.commit.cid, 947 + text: record.text, 948 + forumUri: record.forum?.forum.uri ?? null, 949 + boardUri: record.board?.board.uri ?? null, // NEW 950 + boardId, // NEW 951 + rootPostId: rootId, 952 + rootUri: record.reply?.root.uri ?? null, 953 + parentPostId: parentId, 954 + parentUri: record.reply?.parent.uri ?? null, 955 + createdAt: new Date(record.createdAt), 956 + indexedAt: new Date(), 957 + }; 958 + }, 959 + ``` 960 + 961 + **Step 4: Update postConfig toUpdateValues** 962 + 963 + ```typescript 964 + toUpdateValues: async (event, record) => ({ 965 + cid: event.commit.cid, 966 + text: record.text, 967 + forumUri: record.forum?.forum.uri ?? null, 968 + boardUri: record.board?.board.uri ?? null, // NEW 969 + indexedAt: new Date(), 970 + }), 971 + ``` 972 + 973 + **Step 5: Run test to verify it passes** 974 + 975 + ```bash 976 + pnpm --filter @atbb/appview test src/lib/__tests__/firehose.test.ts -t "indexes post with board" 977 + ``` 978 + 979 + Expected: PASS 980 + 981 + **Step 6: Run all indexer tests** 982 + 983 + ```bash 984 + pnpm --filter @atbb/appview test src/lib/__tests__/firehose.test.ts 985 + ``` 986 + 987 + Expected: All tests pass 988 + 989 + **Step 7: Commit** 990 + 991 + ```bash 992 + git add apps/appview/src/lib/indexer.ts 993 + git add apps/appview/src/lib/__tests__/firehose.test.ts 994 + git commit -m "feat(indexer): extract board URI from posts 995 + 996 + - Posts now store boardUri and boardId 997 + - Resolves board AT URI to database ID 998 + - Handles posts without board gracefully (null)" 999 + ``` 1000 + 1001 + --- 1002 + 1003 + ### Task 8: Register Board Handlers in FirehoseService 1004 + 1005 + **Files:** 1006 + - Modify: `apps/appview/src/lib/firehose.ts:86-124` 1007 + 1008 + **Step 1: Add board handler registration** 1009 + 1010 + Update `createHandlerRegistry` in `apps/appview/src/lib/firehose.ts`: 1011 + 1012 + ```typescript 1013 + private createHandlerRegistry(): EventHandlerRegistry { 1014 + return new EventHandlerRegistry() 1015 + .register({ 1016 + collection: "space.atbb.post", 1017 + onCreate: this.createWrappedHandler("handlePostCreate"), 1018 + onUpdate: this.createWrappedHandler("handlePostUpdate"), 1019 + onDelete: this.createWrappedHandler("handlePostDelete"), 1020 + }) 1021 + .register({ 1022 + collection: "space.atbb.forum.forum", 1023 + onCreate: this.createWrappedHandler("handleForumCreate"), 1024 + onUpdate: this.createWrappedHandler("handleForumUpdate"), 1025 + onDelete: this.createWrappedHandler("handleForumDelete"), 1026 + }) 1027 + .register({ 1028 + collection: "space.atbb.forum.category", 1029 + onCreate: this.createWrappedHandler("handleCategoryCreate"), 1030 + onUpdate: this.createWrappedHandler("handleCategoryUpdate"), 1031 + onDelete: this.createWrappedHandler("handleCategoryDelete"), 1032 + }) 1033 + .register({ // NEW 1034 + collection: "space.atbb.forum.board", 1035 + onCreate: this.createWrappedHandler("handleBoardCreate"), 1036 + onUpdate: this.createWrappedHandler("handleBoardUpdate"), 1037 + onDelete: this.createWrappedHandler("handleBoardDelete"), 1038 + }) 1039 + .register({ 1040 + collection: "space.atbb.membership", 1041 + onCreate: this.createWrappedHandler("handleMembershipCreate"), 1042 + onUpdate: this.createWrappedHandler("handleMembershipUpdate"), 1043 + onDelete: this.createWrappedHandler("handleMembershipDelete"), 1044 + }) 1045 + .register({ 1046 + collection: "space.atbb.modAction", 1047 + onCreate: this.createWrappedHandler("handleModActionCreate"), 1048 + onUpdate: this.createWrappedHandler("handleModActionUpdate"), 1049 + onDelete: this.createWrappedHandler("handleModActionDelete"), 1050 + }) 1051 + .register({ 1052 + collection: "space.atbb.reaction", 1053 + onCreate: this.createWrappedHandler("handleReactionCreate"), 1054 + onUpdate: this.createWrappedHandler("handleReactionUpdate"), 1055 + onDelete: this.createWrappedHandler("handleReactionDelete"), 1056 + }); 1057 + } 1058 + ``` 1059 + 1060 + **Step 2: Verify firehose tests still pass** 1061 + 1062 + ```bash 1063 + pnpm --filter @atbb/appview test src/lib/__tests__/firehose.test.ts 1064 + ``` 1065 + 1066 + Expected: All tests pass 1067 + 1068 + **Step 3: Commit** 1069 + 1070 + ```bash 1071 + git add apps/appview/src/lib/firehose.ts 1072 + git commit -m "feat(firehose): register board collection handlers 1073 + 1074 + - Firehose now subscribes to space.atbb.forum.board 1075 + - Board create/update/delete events route to indexer" 1076 + ``` 1077 + 1078 + --- 1079 + 1080 + ## Phase 3: API - Boards Endpoints 1081 + 1082 + ### Task 9: Create Board Helper Functions 1083 + 1084 + **Files:** 1085 + - Modify: `apps/appview/src/routes/helpers.ts:end` 1086 + 1087 + **Step 1: Write test for getBoardByUri** 1088 + 1089 + Create `apps/appview/src/routes/__tests__/helpers-boards.test.ts`: 1090 + 1091 + ```typescript 1092 + import { describe, it, expect, beforeEach } from "vitest"; 1093 + import { getBoardByUri, serializeBoard } from "../helpers.js"; 1094 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 1095 + import { boards, categories, forums } from "@atbb/db"; 1096 + 1097 + describe("Board Helpers", () => { 1098 + let ctx: TestContext; 1099 + 1100 + beforeEach(async () => { 1101 + ctx = await createTestContext(); 1102 + 1103 + await ctx.db.insert(forums).values({ 1104 + did: "did:plc:forum", 1105 + rkey: "self", 1106 + cid: "bafyforum", 1107 + name: "Forum", 1108 + indexedAt: new Date(), 1109 + }); 1110 + 1111 + const [category] = await ctx.db.insert(categories).values({ 1112 + did: "did:plc:forum", 1113 + rkey: "cat1", 1114 + cid: "bafycat", 1115 + name: "Category", 1116 + forumId: 1n, 1117 + createdAt: new Date(), 1118 + indexedAt: new Date(), 1119 + }).returning(); 1120 + 1121 + await ctx.db.insert(boards).values({ 1122 + did: "did:plc:forum", 1123 + rkey: "board1", 1124 + cid: "bafyboard", 1125 + name: "General", 1126 + description: "General discussion", 1127 + slug: "general", 1128 + sortOrder: 1, 1129 + categoryId: category.id, 1130 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/cat1", 1131 + createdAt: new Date("2026-02-13T00:00:00Z"), 1132 + indexedAt: new Date(), 1133 + }); 1134 + }); 1135 + 1136 + describe("getBoardByUri", () => { 1137 + it("returns board CID for valid URI", async () => { 1138 + const result = await getBoardByUri( 1139 + ctx.db, 1140 + "at://did:plc:forum/space.atbb.forum.board/board1" 1141 + ); 1142 + 1143 + expect(result).toEqual({ cid: "bafyboard" }); 1144 + }); 1145 + 1146 + it("returns null for non-existent board", async () => { 1147 + const result = await getBoardByUri( 1148 + ctx.db, 1149 + "at://did:plc:forum/space.atbb.forum.board/nonexistent" 1150 + ); 1151 + 1152 + expect(result).toBeNull(); 1153 + }); 1154 + }); 1155 + 1156 + describe("serializeBoard", () => { 1157 + it("serializes board with all fields", async () => { 1158 + const [board] = await ctx.db.select().from(boards); 1159 + const serialized = serializeBoard(board); 1160 + 1161 + expect(serialized).toEqual({ 1162 + id: "1", 1163 + name: "General", 1164 + description: "General discussion", 1165 + slug: "general", 1166 + sortOrder: 1, 1167 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/cat1", 1168 + createdAt: "2026-02-13T00:00:00.000Z", 1169 + }); 1170 + }); 1171 + 1172 + it("serializes board with null optional fields", async () => { 1173 + await ctx.db.insert(boards).values({ 1174 + did: "did:plc:forum", 1175 + rkey: "board2", 1176 + cid: "bafyboard2", 1177 + name: "Minimal", 1178 + categoryId: 1n, 1179 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/cat1", 1180 + createdAt: new Date("2026-02-13T00:00:00Z"), 1181 + indexedAt: new Date(), 1182 + }); 1183 + 1184 + const [board] = await ctx.db 1185 + .select() 1186 + .from(boards) 1187 + .where(eq(boards.rkey, "board2")); 1188 + 1189 + const serialized = serializeBoard(board); 1190 + 1191 + expect(serialized).toEqual({ 1192 + id: "2", 1193 + name: "Minimal", 1194 + description: null, 1195 + slug: null, 1196 + sortOrder: null, 1197 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/cat1", 1198 + createdAt: "2026-02-13T00:00:00.000Z", 1199 + }); 1200 + }); 1201 + }); 1202 + }); 1203 + ``` 1204 + 1205 + **Step 2: Run test to verify it fails** 1206 + 1207 + ```bash 1208 + pnpm --filter @atbb/appview test src/routes/__tests__/helpers-boards.test.ts 1209 + ``` 1210 + 1211 + Expected: FAIL - Functions not defined 1212 + 1213 + **Step 3: Add getBoardByUri helper** 1214 + 1215 + Add to `apps/appview/src/routes/helpers.ts`: 1216 + 1217 + ```typescript 1218 + /** 1219 + * Look up board by AT URI and return CID 1220 + */ 1221 + export async function getBoardByUri( 1222 + db: Database, 1223 + uri: string 1224 + ): Promise<{ cid: string } | null> { 1225 + const { did, rkey } = parseAtUri(uri); 1226 + const [result] = await db 1227 + .select({ cid: boards.cid }) 1228 + .from(boards) 1229 + .where(and(eq(boards.did, did), eq(boards.rkey, rkey))) 1230 + .limit(1); 1231 + return result ?? null; 1232 + } 1233 + ``` 1234 + 1235 + **Step 4: Add serializeBoard helper** 1236 + 1237 + ```typescript 1238 + /** 1239 + * Serialize board database record to API format 1240 + */ 1241 + export function serializeBoard(board: any) { 1242 + return { 1243 + id: board.id.toString(), 1244 + name: board.name, 1245 + description: board.description, 1246 + slug: board.slug, 1247 + sortOrder: board.sortOrder, 1248 + categoryUri: board.categoryUri, 1249 + createdAt: board.createdAt.toISOString(), 1250 + }; 1251 + } 1252 + ``` 1253 + 1254 + **Step 5: Add boards import** 1255 + 1256 + Update imports in `apps/appview/src/routes/helpers.ts`: 1257 + 1258 + ```typescript 1259 + import { posts, categories, forums, boards, users } from "@atbb/db"; 1260 + ``` 1261 + 1262 + **Step 6: Run test to verify it passes** 1263 + 1264 + ```bash 1265 + pnpm --filter @atbb/appview test src/routes/__tests__/helpers-boards.test.ts 1266 + ``` 1267 + 1268 + Expected: PASS 1269 + 1270 + **Step 7: Commit** 1271 + 1272 + ```bash 1273 + git add apps/appview/src/routes/helpers.ts 1274 + git add apps/appview/src/routes/__tests__/helpers-boards.test.ts 1275 + git commit -m "feat(api): add board helper functions 1276 + 1277 + - getBoardByUri: lookup board CID by AT URI 1278 + - serializeBoard: convert DB record to API format 1279 + - Tests verify lookup and serialization" 1280 + ``` 1281 + 1282 + --- 1283 + 1284 + ### Task 10: Create GET /api/boards Endpoint 1285 + 1286 + **Files:** 1287 + - Create: `apps/appview/src/routes/boards.ts` 1288 + - Create test: `apps/appview/src/routes/__tests__/boards.test.ts` 1289 + 1290 + **Step 1: Write test for GET /api/boards** 1291 + 1292 + Create `apps/appview/src/routes/__tests__/boards.test.ts`: 1293 + 1294 + ```typescript 1295 + import { describe, it, expect, beforeEach } from "vitest"; 1296 + import { Hono } from "hono"; 1297 + import { createBoardsRoutes } from "../boards.js"; 1298 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 1299 + import { boards, categories } from "@atbb/db"; 1300 + 1301 + describe("GET /api/boards", () => { 1302 + let ctx: TestContext; 1303 + let app: Hono; 1304 + 1305 + beforeEach(async () => { 1306 + ctx = await createTestContext(); 1307 + app = new Hono().route("/api/boards", createBoardsRoutes(ctx)); 1308 + 1309 + // Create categories 1310 + const [cat1] = await ctx.db.insert(categories).values({ 1311 + did: "did:plc:forum", 1312 + rkey: "cat1", 1313 + cid: "bafycat1", 1314 + name: "General", 1315 + forumId: 1n, 1316 + sortOrder: 1, 1317 + createdAt: new Date(), 1318 + indexedAt: new Date(), 1319 + }).returning(); 1320 + 1321 + const [cat2] = await ctx.db.insert(categories).values({ 1322 + did: "did:plc:forum", 1323 + rkey: "cat2", 1324 + cid: "bafycat2", 1325 + name: "Technical", 1326 + forumId: 1n, 1327 + sortOrder: 2, 1328 + createdAt: new Date(), 1329 + indexedAt: new Date(), 1330 + }).returning(); 1331 + 1332 + // Create boards 1333 + await ctx.db.insert(boards).values([ 1334 + { 1335 + did: "did:plc:forum", 1336 + rkey: "board1", 1337 + cid: "bafyboard1", 1338 + name: "Announcements", 1339 + sortOrder: 1, 1340 + categoryId: cat1.id, 1341 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/cat1", 1342 + createdAt: new Date(), 1343 + indexedAt: new Date(), 1344 + }, 1345 + { 1346 + did: "did:plc:forum", 1347 + rkey: "board2", 1348 + cid: "bafyboard2", 1349 + name: "Off-Topic", 1350 + sortOrder: 2, 1351 + categoryId: cat1.id, 1352 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/cat1", 1353 + createdAt: new Date(), 1354 + indexedAt: new Date(), 1355 + }, 1356 + { 1357 + did: "did:plc:forum", 1358 + rkey: "board3", 1359 + cid: "bafyboard3", 1360 + name: "API Development", 1361 + sortOrder: 1, 1362 + categoryId: cat2.id, 1363 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/cat2", 1364 + createdAt: new Date(), 1365 + indexedAt: new Date(), 1366 + }, 1367 + ]); 1368 + }); 1369 + 1370 + it("returns all boards grouped by category", async () => { 1371 + const res = await app.request("/api/boards"); 1372 + expect(res.status).toBe(200); 1373 + 1374 + const data = await res.json(); 1375 + expect(data.boards).toHaveLength(3); 1376 + expect(data.boards[0].name).toBe("Announcements"); 1377 + expect(data.boards[1].name).toBe("Off-Topic"); 1378 + expect(data.boards[2].name).toBe("API Development"); 1379 + }); 1380 + 1381 + it("returns empty array when no boards exist", async () => { 1382 + // Clear boards 1383 + await ctx.db.delete(boards); 1384 + 1385 + const res = await app.request("/api/boards"); 1386 + expect(res.status).toBe(200); 1387 + 1388 + const data = await res.json(); 1389 + expect(data.boards).toEqual([]); 1390 + }); 1391 + 1392 + it("returns 500 on database error", async () => { 1393 + await ctx.destroyTestContext(); 1394 + 1395 + const res = await app.request("/api/boards"); 1396 + expect(res.status).toBe(500); 1397 + 1398 + const data = await res.json(); 1399 + expect(data.error).toBe( 1400 + "Failed to retrieve boards. Please try again later." 1401 + ); 1402 + }); 1403 + }); 1404 + ``` 1405 + 1406 + **Step 2: Run test to verify it fails** 1407 + 1408 + ```bash 1409 + pnpm --filter @atbb/appview test src/routes/__tests__/boards.test.ts 1410 + ``` 1411 + 1412 + Expected: FAIL - Module not found 1413 + 1414 + **Step 3: Create boards.ts route** 1415 + 1416 + Create `apps/appview/src/routes/boards.ts`: 1417 + 1418 + ```typescript 1419 + import { Hono } from "hono"; 1420 + import type { AppContext } from "../lib/app-context.js"; 1421 + import { boards, categories } from "@atbb/db"; 1422 + import { asc } from "drizzle-orm"; 1423 + import { serializeBoard } from "./helpers.js"; 1424 + 1425 + /** 1426 + * Factory function that creates board routes with access to app context. 1427 + */ 1428 + export function createBoardsRoutes(ctx: AppContext) { 1429 + return new Hono().get("/", async (c) => { 1430 + try { 1431 + const allBoards = await ctx.db 1432 + .select() 1433 + .from(boards) 1434 + .leftJoin(categories, eq(boards.categoryId, categories.id)) 1435 + .orderBy(asc(categories.sortOrder), asc(boards.sortOrder)) 1436 + .limit(1000); // Defensive limit 1437 + 1438 + return c.json({ 1439 + boards: allBoards.map(({ boards: board }) => serializeBoard(board)), 1440 + }); 1441 + } catch (error) { 1442 + console.error("Failed to query boards", { 1443 + operation: "GET /api/boards", 1444 + error: error instanceof Error ? error.message : String(error), 1445 + }); 1446 + 1447 + return c.json( 1448 + { 1449 + error: "Failed to retrieve boards. Please try again later.", 1450 + }, 1451 + 500 1452 + ); 1453 + } 1454 + }); 1455 + } 1456 + ``` 1457 + 1458 + **Step 4: Add eq import** 1459 + 1460 + Update imports: 1461 + 1462 + ```typescript 1463 + import { asc, eq } from "drizzle-orm"; 1464 + ``` 1465 + 1466 + **Step 5: Run test to verify it passes** 1467 + 1468 + ```bash 1469 + pnpm --filter @atbb/appview test src/routes/__tests__/boards.test.ts 1470 + ``` 1471 + 1472 + Expected: PASS 1473 + 1474 + **Step 6: Commit** 1475 + 1476 + ```bash 1477 + git add apps/appview/src/routes/boards.ts 1478 + git add apps/appview/src/routes/__tests__/boards.test.ts 1479 + git commit -m "feat(api): add GET /api/boards endpoint 1480 + 1481 + - Returns all boards sorted by category, then board sortOrder 1482 + - Defensive 1000 limit to prevent memory exhaustion 1483 + - Error handling with structured logging" 1484 + ``` 1485 + 1486 + --- 1487 + 1488 + ### Task 11: Register Board Routes in App 1489 + 1490 + **Files:** 1491 + - Modify: `apps/appview/src/app.ts` 1492 + 1493 + **Step 1: Import and register boards routes** 1494 + 1495 + Update `apps/appview/src/app.ts`: 1496 + 1497 + ```typescript 1498 + import { createBoardsRoutes } from "./routes/boards.js"; 1499 + 1500 + // ... existing code ... 1501 + 1502 + // Register routes 1503 + app.route("/api/forum", createForumRoutes(ctx)); 1504 + app.route("/api/categories", createCategoriesRoutes(ctx)); 1505 + app.route("/api/boards", createBoardsRoutes(ctx)); // NEW 1506 + app.route("/api/topics", createTopicsRoutes(ctx)); 1507 + app.route("/api/health", createHealthRoutes(ctx)); 1508 + app.route("/api/oauth", createOAuthRoutes(ctx)); 1509 + ``` 1510 + 1511 + **Step 2: Run all tests to verify integration** 1512 + 1513 + ```bash 1514 + pnpm --filter @atbb/appview test 1515 + ``` 1516 + 1517 + Expected: All tests pass 1518 + 1519 + **Step 3: Commit** 1520 + 1521 + ```bash 1522 + git add apps/appview/src/app.ts 1523 + git commit -m "feat(api): register boards routes in app 1524 + 1525 + - GET /api/boards now accessible 1526 + - Follows existing route registration pattern" 1527 + ``` 1528 + 1529 + --- 1530 + 1531 + ### Task 12: Update POST /api/topics to Require boardUri 1532 + 1533 + **Files:** 1534 + - Modify: `apps/appview/src/routes/topics.ts:82-175` 1535 + - Modify: `apps/appview/src/routes/__tests__/topics.test.ts` 1536 + 1537 + **Step 1: Write test for POST with boardUri** 1538 + 1539 + Add to `apps/appview/src/routes/__tests__/topics.test.ts`: 1540 + 1541 + ```typescript 1542 + it("POST /api/topics creates topic with board reference", async () => { 1543 + // Setup: create board 1544 + const [category] = await ctx.db.insert(categories).values({ 1545 + did: ctx.config.forumDid, 1546 + rkey: "cat1", 1547 + cid: "bafycat", 1548 + name: "Category", 1549 + forumId: 1n, 1550 + createdAt: new Date(), 1551 + indexedAt: new Date(), 1552 + }).returning(); 1553 + 1554 + await ctx.db.insert(boards).values({ 1555 + did: ctx.config.forumDid, 1556 + rkey: "board1", 1557 + cid: "bafyboard", 1558 + name: "General", 1559 + categoryId: category.id, 1560 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/cat1`, 1561 + createdAt: new Date(), 1562 + indexedAt: new Date(), 1563 + }); 1564 + 1565 + const boardUri = `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`; 1566 + 1567 + const res = await app.request("/api/topics", { 1568 + method: "POST", 1569 + headers: { 1570 + "Content-Type": "application/json", 1571 + Cookie: await getAuthCookie(ctx), 1572 + }, 1573 + body: JSON.stringify({ 1574 + text: "Test topic with board", 1575 + boardUri, 1576 + }), 1577 + }); 1578 + 1579 + expect(res.status).toBe(201); 1580 + const data = await res.json(); 1581 + expect(data.uri).toBeDefined(); 1582 + expect(data.cid).toBeDefined(); 1583 + 1584 + // Verify putRecord was called with board reference 1585 + const calls = mockPutRecord.mock.calls; 1586 + expect(calls[calls.length - 1][0].record).toMatchObject({ 1587 + text: "Test topic with board", 1588 + forum: { 1589 + forum: { 1590 + uri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1591 + }, 1592 + }, 1593 + board: { 1594 + board: { 1595 + uri: boardUri, 1596 + cid: "bafyboard", 1597 + }, 1598 + }, 1599 + }); 1600 + }); 1601 + 1602 + it("POST /api/topics returns 400 when boardUri missing", async () => { 1603 + const res = await app.request("/api/topics", { 1604 + method: "POST", 1605 + headers: { 1606 + "Content-Type": "application/json", 1607 + Cookie: await getAuthCookie(ctx), 1608 + }, 1609 + body: JSON.stringify({ 1610 + text: "Topic without board", 1611 + }), 1612 + }); 1613 + 1614 + expect(res.status).toBe(400); 1615 + const data = await res.json(); 1616 + expect(data.error).toBe("boardUri is required"); 1617 + }); 1618 + 1619 + it("POST /api/topics returns 404 when board not found", async () => { 1620 + const res = await app.request("/api/topics", { 1621 + method: "POST", 1622 + headers: { 1623 + "Content-Type": "application/json", 1624 + Cookie: await getAuthCookie(ctx), 1625 + }, 1626 + body: JSON.stringify({ 1627 + text: "Topic with invalid board", 1628 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/nonexistent`, 1629 + }), 1630 + }); 1631 + 1632 + expect(res.status).toBe(404); 1633 + const data = await res.json(); 1634 + expect(data.error).toBe("Board not found"); 1635 + }); 1636 + ``` 1637 + 1638 + **Step 2: Run test to verify it fails** 1639 + 1640 + ```bash 1641 + pnpm --filter @atbb/appview test src/routes/__tests__/topics.test.ts -t "POST /api/topics" 1642 + ``` 1643 + 1644 + Expected: FAIL - Tests expect boardUri handling 1645 + 1646 + **Step 3: Update POST /api/topics handler** 1647 + 1648 + Modify `apps/appview/src/routes/topics.ts`: 1649 + 1650 + ```typescript 1651 + .post("/", requireAuth(ctx), async (c) => { 1652 + const user = c.get("user")!; 1653 + 1654 + let body: any; 1655 + try { 1656 + body = await c.req.json(); 1657 + } catch { 1658 + return c.json({ error: "Invalid JSON in request body" }, 400); 1659 + } 1660 + 1661 + const { text, boardUri } = body; 1662 + 1663 + // Validate text 1664 + const validation = validatePostText(text); 1665 + if (!validation.valid) { 1666 + return c.json({ error: validation.error }, 400); 1667 + } 1668 + 1669 + // Validate boardUri is required 1670 + if (typeof boardUri !== "string" || !boardUri.trim()) { 1671 + return c.json({ error: "boardUri is required" }, 400); 1672 + } 1673 + 1674 + try { 1675 + // Always use the configured singleton forum 1676 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 1677 + 1678 + // Look up forum to get CID 1679 + const forum = await getForumByUri(ctx.db, forumUri); 1680 + if (!forum) { 1681 + return c.json({ error: "Forum not found" }, 404); 1682 + } 1683 + 1684 + // Look up board to get CID 1685 + const board = await getBoardByUri(ctx.db, boardUri); 1686 + if (!board) { 1687 + return c.json({ error: "Board not found" }, 404); 1688 + } 1689 + 1690 + // Generate TID for rkey 1691 + const rkey = TID.nextStr(); 1692 + 1693 + // Write to user's PDS 1694 + const result = await user.agent.com.atproto.repo.putRecord({ 1695 + repo: user.did, 1696 + collection: "space.atbb.post", 1697 + rkey, 1698 + record: { 1699 + $type: "space.atbb.post", 1700 + text: validation.trimmed!, 1701 + forum: { 1702 + forum: { uri: forumUri, cid: forum.cid }, 1703 + }, 1704 + board: { 1705 + board: { uri: boardUri, cid: board.cid }, 1706 + }, 1707 + createdAt: new Date().toISOString(), 1708 + }, 1709 + }); 1710 + 1711 + return c.json( 1712 + { 1713 + uri: result.data.uri, 1714 + cid: result.data.cid, 1715 + rkey, 1716 + }, 1717 + 201 1718 + ); 1719 + } catch (error) { 1720 + // ... existing error handling ... 1721 + } 1722 + }); 1723 + ``` 1724 + 1725 + **Step 4: Import getBoardByUri** 1726 + 1727 + Update imports: 1728 + 1729 + ```typescript 1730 + import { 1731 + parseBigIntParam, 1732 + serializePost, 1733 + validatePostText, 1734 + getForumByUri, 1735 + getBoardByUri, // NEW 1736 + isProgrammingError, 1737 + isNetworkError, 1738 + } from "./helpers.js"; 1739 + ``` 1740 + 1741 + **Step 5: Run test to verify it passes** 1742 + 1743 + ```bash 1744 + pnpm --filter @atbb/appview test src/routes/__tests__/topics.test.ts -t "POST /api/topics" 1745 + ``` 1746 + 1747 + Expected: PASS 1748 + 1749 + **Step 6: Run all topic tests** 1750 + 1751 + ```bash 1752 + pnpm --filter @atbb/appview test src/routes/__tests__/topics.test.ts 1753 + ``` 1754 + 1755 + Expected: All tests pass 1756 + 1757 + **Step 7: Commit** 1758 + 1759 + ```bash 1760 + git add apps/appview/src/routes/topics.ts 1761 + git add apps/appview/src/routes/__tests__/topics.test.ts 1762 + git commit -m "feat(api): require boardUri in POST /api/topics 1763 + 1764 + - boardUri is now required (returns 400 if missing) 1765 + - Validates board exists before writing to PDS 1766 + - Writes both forum and board references to post record 1767 + - forumUri always uses configured singleton" 1768 + ``` 1769 + 1770 + --- 1771 + 1772 + ## Phase 4: Additional Board Endpoints 1773 + 1774 + ### Task 13: Create GET /api/boards/:id/topics Endpoint 1775 + 1776 + **Files:** 1777 + - Modify: `apps/appview/src/routes/boards.ts` 1778 + - Modify: `apps/appview/src/routes/__tests__/boards.test.ts` 1779 + 1780 + **Step 1: Write test for GET /api/boards/:id/topics** 1781 + 1782 + Add to `apps/appview/src/routes/__tests__/boards.test.ts`: 1783 + 1784 + ```typescript 1785 + import { posts, users } from "@atbb/db"; 1786 + 1787 + describe("GET /api/boards/:id/topics", () => { 1788 + beforeEach(async () => { 1789 + // Create user 1790 + await ctx.db.insert(users).values({ 1791 + did: "did:plc:user", 1792 + handle: "user.test", 1793 + indexedAt: new Date(), 1794 + }); 1795 + 1796 + // Create posts (topics) in board 1 1797 + await ctx.db.insert(posts).values([ 1798 + { 1799 + did: "did:plc:user", 1800 + rkey: "post1", 1801 + cid: "bafypost1", 1802 + text: "First topic", 1803 + boardId: 1n, 1804 + boardUri: "at://did:plc:forum/space.atbb.forum.board/board1", 1805 + forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 1806 + createdAt: new Date("2026-02-13T10:00:00Z"), 1807 + indexedAt: new Date(), 1808 + }, 1809 + { 1810 + did: "did:plc:user", 1811 + rkey: "post2", 1812 + cid: "bafypost2", 1813 + text: "Second topic", 1814 + boardId: 1n, 1815 + boardUri: "at://did:plc:forum/space.atbb.forum.board/board1", 1816 + forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 1817 + createdAt: new Date("2026-02-13T11:00:00Z"), 1818 + indexedAt: new Date(), 1819 + }, 1820 + ]); 1821 + }); 1822 + 1823 + it("returns topics for board", async () => { 1824 + const res = await app.request("/api/boards/1/topics"); 1825 + expect(res.status).toBe(200); 1826 + 1827 + const data = await res.json(); 1828 + expect(data.topics).toHaveLength(2); 1829 + expect(data.topics[0].text).toBe("Second topic"); // Newest first 1830 + expect(data.topics[1].text).toBe("First topic"); 1831 + }); 1832 + 1833 + it("returns empty array for board with no topics", async () => { 1834 + const res = await app.request("/api/boards/2/topics"); 1835 + expect(res.status).toBe(200); 1836 + 1837 + const data = await res.json(); 1838 + expect(data.topics).toEqual([]); 1839 + }); 1840 + 1841 + it("returns 400 for invalid board ID", async () => { 1842 + const res = await app.request("/api/boards/invalid/topics"); 1843 + expect(res.status).toBe(400); 1844 + 1845 + const data = await res.json(); 1846 + expect(data.error).toBe("Invalid board ID format"); 1847 + }); 1848 + 1849 + it("filters out deleted posts", async () => { 1850 + await ctx.db.insert(posts).values({ 1851 + did: "did:plc:user", 1852 + rkey: "post3", 1853 + cid: "bafypost3", 1854 + text: "Deleted topic", 1855 + boardId: 1n, 1856 + boardUri: "at://did:plc:forum/space.atbb.forum.board/board1", 1857 + deleted: true, 1858 + createdAt: new Date("2026-02-13T12:00:00Z"), 1859 + indexedAt: new Date(), 1860 + }); 1861 + 1862 + const res = await app.request("/api/boards/1/topics"); 1863 + expect(res.status).toBe(200); 1864 + 1865 + const data = await res.json(); 1866 + expect(data.topics).toHaveLength(2); // Still 2, not 3 1867 + }); 1868 + }); 1869 + ``` 1870 + 1871 + **Step 2: Run test to verify it fails** 1872 + 1873 + ```bash 1874 + pnpm --filter @atbb/appview test src/routes/__tests__/boards.test.ts -t "GET /api/boards/:id/topics" 1875 + ``` 1876 + 1877 + Expected: FAIL - Route not found 1878 + 1879 + **Step 3: Add GET /:id/topics route** 1880 + 1881 + Add to `apps/appview/src/routes/boards.ts`: 1882 + 1883 + ```typescript 1884 + import { posts, users } from "@atbb/db"; 1885 + import { eq, and, desc, isNull } from "drizzle-orm"; 1886 + import { parseBigIntParam, serializePost } from "./helpers.js"; 1887 + 1888 + export function createBoardsRoutes(ctx: AppContext) { 1889 + return new Hono() 1890 + .get("/", async (c) => { 1891 + // ... existing code ... 1892 + }) 1893 + .get("/:id/topics", async (c) => { 1894 + const { id } = c.req.param(); 1895 + 1896 + const boardId = parseBigIntParam(id); 1897 + if (boardId === null) { 1898 + return c.json({ error: "Invalid board ID format" }, 400); 1899 + } 1900 + 1901 + try { 1902 + const topicResults = await ctx.db 1903 + .select({ 1904 + post: posts, 1905 + author: users, 1906 + }) 1907 + .from(posts) 1908 + .leftJoin(users, eq(posts.did, users.did)) 1909 + .where( 1910 + and( 1911 + eq(posts.boardId, boardId), 1912 + isNull(posts.rootPostId), // Topics only (not replies) 1913 + eq(posts.deleted, false) 1914 + ) 1915 + ) 1916 + .orderBy(desc(posts.createdAt)) 1917 + .limit(1000); // Defensive limit 1918 + 1919 + return c.json({ 1920 + topics: topicResults.map(({ post, author }) => 1921 + serializePost(post, author) 1922 + ), 1923 + }); 1924 + } catch (error) { 1925 + console.error("Failed to query board topics", { 1926 + operation: "GET /api/boards/:id/topics", 1927 + boardId: id, 1928 + error: error instanceof Error ? error.message : String(error), 1929 + }); 1930 + 1931 + return c.json( 1932 + { 1933 + error: "Failed to retrieve topics. Please try again later.", 1934 + }, 1935 + 500 1936 + ); 1937 + } 1938 + }); 1939 + } 1940 + ``` 1941 + 1942 + **Step 4: Update imports** 1943 + 1944 + ```typescript 1945 + import { boards, categories, posts, users } from "@atbb/db"; 1946 + import { asc, eq, and, desc, isNull } from "drizzle-orm"; 1947 + import { serializeBoard, parseBigIntParam, serializePost } from "./helpers.js"; 1948 + ``` 1949 + 1950 + **Step 5: Run test to verify it passes** 1951 + 1952 + ```bash 1953 + pnpm --filter @atbb/appview test src/routes/__tests__/boards.test.ts 1954 + ``` 1955 + 1956 + Expected: All tests pass 1957 + 1958 + **Step 6: Commit** 1959 + 1960 + ```bash 1961 + git add apps/appview/src/routes/boards.ts 1962 + git add apps/appview/src/routes/__tests__/boards.test.ts 1963 + git commit -m "feat(api): add GET /api/boards/:id/topics endpoint 1964 + 1965 + - Returns topics (posts with NULL root) for a board 1966 + - Sorted by createdAt DESC (newest first) 1967 + - Filters out deleted posts 1968 + - Defensive 1000 limit" 1969 + ``` 1970 + 1971 + --- 1972 + 1973 + ### Task 14: Create GET /api/categories/:id/boards Endpoint 1974 + 1975 + **Files:** 1976 + - Modify: `apps/appview/src/routes/categories.ts` 1977 + - Modify: `apps/appview/src/routes/__tests__/categories.test.ts` 1978 + 1979 + **Step 1: Write test for GET /api/categories/:id/boards** 1980 + 1981 + Create `apps/appview/src/routes/__tests__/categories.test.ts` if it doesn't exist, or add: 1982 + 1983 + ```typescript 1984 + import { describe, it, expect, beforeEach } from "vitest"; 1985 + import { Hono } from "hono"; 1986 + import { createCategoriesRoutes } from "../categories.js"; 1987 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 1988 + import { categories, boards } from "@atbb/db"; 1989 + 1990 + describe("GET /api/categories/:id/boards", () => { 1991 + let ctx: TestContext; 1992 + let app: Hono; 1993 + 1994 + beforeEach(async () => { 1995 + ctx = await createTestContext(); 1996 + app = new Hono().route("/api/categories", createCategoriesRoutes(ctx)); 1997 + 1998 + const [cat1] = await ctx.db.insert(categories).values({ 1999 + did: "did:plc:forum", 2000 + rkey: "cat1", 2001 + cid: "bafycat1", 2002 + name: "General", 2003 + forumId: 1n, 2004 + sortOrder: 1, 2005 + createdAt: new Date(), 2006 + indexedAt: new Date(), 2007 + }).returning(); 2008 + 2009 + await ctx.db.insert(boards).values([ 2010 + { 2011 + did: "did:plc:forum", 2012 + rkey: "board1", 2013 + cid: "bafyboard1", 2014 + name: "Announcements", 2015 + sortOrder: 1, 2016 + categoryId: cat1.id, 2017 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/cat1", 2018 + createdAt: new Date(), 2019 + indexedAt: new Date(), 2020 + }, 2021 + { 2022 + did: "did:plc:forum", 2023 + rkey: "board2", 2024 + cid: "bafyboard2", 2025 + name: "Off-Topic", 2026 + sortOrder: 2, 2027 + categoryId: cat1.id, 2028 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/cat1", 2029 + createdAt: new Date(), 2030 + indexedAt: new Date(), 2031 + }, 2032 + ]); 2033 + }); 2034 + 2035 + it("returns boards for category", async () => { 2036 + const res = await app.request("/api/categories/1/boards"); 2037 + expect(res.status).toBe(200); 2038 + 2039 + const data = await res.json(); 2040 + expect(data.boards).toHaveLength(2); 2041 + expect(data.boards[0].name).toBe("Announcements"); 2042 + expect(data.boards[1].name).toBe("Off-Topic"); 2043 + }); 2044 + 2045 + it("returns empty array for category with no boards", async () => { 2046 + const [cat2] = await ctx.db.insert(categories).values({ 2047 + did: "did:plc:forum", 2048 + rkey: "cat2", 2049 + cid: "bafycat2", 2050 + name: "Empty Category", 2051 + forumId: 1n, 2052 + createdAt: new Date(), 2053 + indexedAt: new Date(), 2054 + }).returning(); 2055 + 2056 + const res = await app.request(`/api/categories/${cat2.id}/boards`); 2057 + expect(res.status).toBe(200); 2058 + 2059 + const data = await res.json(); 2060 + expect(data.boards).toEqual([]); 2061 + }); 2062 + 2063 + it("returns 400 for invalid category ID", async () => { 2064 + const res = await app.request("/api/categories/invalid/boards"); 2065 + expect(res.status).toBe(400); 2066 + 2067 + const data = await res.json(); 2068 + expect(data.error).toBe("Invalid category ID format"); 2069 + }); 2070 + }); 2071 + ``` 2072 + 2073 + **Step 2: Run test to verify it fails** 2074 + 2075 + ```bash 2076 + pnpm --filter @atbb/appview test src/routes/__tests__/categories.test.ts 2077 + ``` 2078 + 2079 + Expected: FAIL - Route not found 2080 + 2081 + **Step 3: Add GET /:id/boards route** 2082 + 2083 + Modify `apps/appview/src/routes/categories.ts`: 2084 + 2085 + ```typescript 2086 + import { Hono } from "hono"; 2087 + import type { AppContext } from "../lib/app-context.js"; 2088 + import { categories, boards } from "@atbb/db"; 2089 + import { eq, asc } from "drizzle-orm"; 2090 + import { serializeCategory, serializeBoard, parseBigIntParam } from "./helpers.js"; 2091 + 2092 + export function createCategoriesRoutes(ctx: AppContext) { 2093 + return new Hono() 2094 + .get("/", async (c) => { 2095 + // ... existing code ... 2096 + }) 2097 + .get("/:id/boards", async (c) => { 2098 + const { id } = c.req.param(); 2099 + 2100 + const categoryId = parseBigIntParam(id); 2101 + if (categoryId === null) { 2102 + return c.json({ error: "Invalid category ID format" }, 400); 2103 + } 2104 + 2105 + try { 2106 + const categoryBoards = await ctx.db 2107 + .select() 2108 + .from(boards) 2109 + .where(eq(boards.categoryId, categoryId)) 2110 + .orderBy(asc(boards.sortOrder)) 2111 + .limit(1000); // Defensive limit 2112 + 2113 + return c.json({ 2114 + boards: categoryBoards.map(serializeBoard), 2115 + }); 2116 + } catch (error) { 2117 + console.error("Failed to query category boards", { 2118 + operation: "GET /api/categories/:id/boards", 2119 + categoryId: id, 2120 + error: error instanceof Error ? error.message : String(error), 2121 + }); 2122 + 2123 + return c.json( 2124 + { 2125 + error: "Failed to retrieve boards. Please try again later.", 2126 + }, 2127 + 500 2128 + ); 2129 + } 2130 + }); 2131 + } 2132 + ``` 2133 + 2134 + **Step 4: Run test to verify it passes** 2135 + 2136 + ```bash 2137 + pnpm --filter @atbb/appview test src/routes/__tests__/categories.test.ts 2138 + ``` 2139 + 2140 + Expected: All tests pass 2141 + 2142 + **Step 5: Commit** 2143 + 2144 + ```bash 2145 + git add apps/appview/src/routes/categories.ts 2146 + git add apps/appview/src/routes/__tests__/categories.test.ts 2147 + git commit -m "feat(api): add GET /api/categories/:id/boards endpoint 2148 + 2149 + - Returns boards for a specific category 2150 + - Sorted by board sortOrder 2151 + - Defensive 1000 limit" 2152 + ``` 2153 + 2154 + --- 2155 + 2156 + ## Phase 5: Bruno Collections 2157 + 2158 + ### Task 15: Update Bruno Collections 2159 + 2160 + **Files:** 2161 + - Create: `bruno/AppView API/Boards/List All Boards.bru` 2162 + - Create: `bruno/AppView API/Boards/Get Board Topics.bru` 2163 + - Create: `bruno/AppView API/Categories/Get Category Boards.bru` 2164 + - Modify: `bruno/AppView API/Topics/Create Topic.bru` 2165 + 2166 + **Step 1: Create List All Boards.bru** 2167 + 2168 + Create `bruno/AppView API/Boards/List All Boards.bru`: 2169 + 2170 + ```bru 2171 + meta { 2172 + name: List All Boards 2173 + type: http 2174 + seq: 1 2175 + } 2176 + 2177 + get { 2178 + url: {{appview_url}}/api/boards 2179 + } 2180 + 2181 + assert { 2182 + res.status: eq 200 2183 + res.body.boards: isDefined 2184 + } 2185 + 2186 + docs { 2187 + Returns all boards across all categories, sorted by category sortOrder then board sortOrder. 2188 + 2189 + Returns: 2190 + { 2191 + "boards": [ 2192 + { 2193 + "id": "1", 2194 + "name": "Board name", 2195 + "description": "Board description" | null, 2196 + "slug": "board-slug" | null, 2197 + "sortOrder": 1 | null, 2198 + "categoryUri": "at://did:plc:forum/space.atbb.forum.category/rkey", 2199 + "createdAt": "2026-02-13T00:00:00.000Z" 2200 + } 2201 + ] 2202 + } 2203 + 2204 + Error codes: 2205 + - 500: Server error 2206 + } 2207 + ``` 2208 + 2209 + **Step 2: Create Get Board Topics.bru** 2210 + 2211 + Create `bruno/AppView API/Boards/Get Board Topics.bru`: 2212 + 2213 + ```bru 2214 + meta { 2215 + name: Get Board Topics 2216 + type: http 2217 + seq: 2 2218 + } 2219 + 2220 + get { 2221 + url: {{appview_url}}/api/boards/1/topics 2222 + } 2223 + 2224 + assert { 2225 + res.status: eq 200 2226 + res.body.topics: isDefined 2227 + } 2228 + 2229 + docs { 2230 + Returns topics (posts with NULL root) for a specific board, sorted by creation time descending. 2231 + 2232 + Path parameters: 2233 + - id: Board ID (numeric) 2234 + 2235 + Returns: 2236 + { 2237 + "topics": [ 2238 + { 2239 + "id": "123", 2240 + "text": "Topic text", 2241 + "author": { "did": "...", "handle": "..." } | null, 2242 + "createdAt": "2026-02-13T00:00:00.000Z", 2243 + "uri": "at://did/space.atbb.post/rkey" 2244 + } 2245 + ] 2246 + } 2247 + 2248 + Error codes: 2249 + - 400: Invalid board ID format 2250 + - 500: Server error 2251 + } 2252 + ``` 2253 + 2254 + **Step 3: Create Get Category Boards.bru** 2255 + 2256 + Create `bruno/AppView API/Categories/Get Category Boards.bru`: 2257 + 2258 + ```bru 2259 + meta { 2260 + name: Get Category Boards 2261 + type: http 2262 + seq: 2 2263 + } 2264 + 2265 + get { 2266 + url: {{appview_url}}/api/categories/1/boards 2267 + } 2268 + 2269 + assert { 2270 + res.status: eq 200 2271 + res.body.boards: isDefined 2272 + } 2273 + 2274 + docs { 2275 + Returns boards for a specific category, sorted by sortOrder. 2276 + 2277 + Path parameters: 2278 + - id: Category ID (numeric) 2279 + 2280 + Returns: 2281 + { 2282 + "boards": [ 2283 + { 2284 + "id": "1", 2285 + "name": "Board name", 2286 + "description": "Board description" | null, 2287 + "slug": "board-slug" | null, 2288 + "sortOrder": 1 | null, 2289 + "categoryUri": "at://did:plc:forum/space.atbb.forum.category/rkey", 2290 + "createdAt": "2026-02-13T00:00:00.000Z" 2291 + } 2292 + ] 2293 + } 2294 + 2295 + Error codes: 2296 + - 400: Invalid category ID format 2297 + - 500: Server error 2298 + } 2299 + ``` 2300 + 2301 + **Step 4: Update Create Topic.bru** 2302 + 2303 + Modify `bruno/AppView API/Topics/Create Topic.bru`: 2304 + 2305 + ```bru 2306 + meta { 2307 + name: Create Topic 2308 + type: http 2309 + seq: 2 2310 + } 2311 + 2312 + post { 2313 + url: {{appview_url}}/api/topics 2314 + } 2315 + 2316 + headers { 2317 + Content-Type: application/json 2318 + } 2319 + 2320 + body:json { 2321 + { 2322 + "text": "My new topic", 2323 + "boardUri": "at://{{forum_did}}/space.atbb.forum.board/{{board_rkey}}" 2324 + } 2325 + } 2326 + 2327 + assert { 2328 + res.status: eq 201 2329 + res.body.uri: isDefined 2330 + res.body.cid: isDefined 2331 + } 2332 + 2333 + docs { 2334 + Creates a new topic (thread starter post) in a board. 2335 + 2336 + Requires authentication via session cookie. 2337 + 2338 + Body parameters: 2339 + - text: string (required) - Topic text content (1-3000 chars, max 300 graphemes) 2340 + - boardUri: string (required) - AT URI of the board (at://did/space.atbb.forum.board/rkey) 2341 + 2342 + Returns: 2343 + { 2344 + "uri": "at://did:plc:user/space.atbb.post/rkey", 2345 + "cid": "bafyrei...", 2346 + "rkey": "3k..." 2347 + } 2348 + 2349 + The post record written to PDS includes: 2350 + - forum reference (singleton, auto-configured) 2351 + - board reference (from boardUri parameter) 2352 + 2353 + Error codes: 2354 + - 400: Invalid input (missing text, invalid boardUri, malformed JSON) 2355 + - 401: Unauthorized (not authenticated) 2356 + - 404: Board not found 2357 + - 503: Unable to reach PDS (network error, retry later) 2358 + - 500: Server error 2359 + } 2360 + ``` 2361 + 2362 + **Step 5: Add board_rkey to environments** 2363 + 2364 + Update `bruno/environments/local.bru`: 2365 + 2366 + ```bru 2367 + vars { 2368 + appview_url: http://localhost:3000 2369 + forum_did: did:plc:your-forum-did 2370 + board_rkey: board1 2371 + } 2372 + ``` 2373 + 2374 + **Step 6: Test Bruno collections manually** 2375 + 2376 + Start the server and test each endpoint in Bruno: 2377 + 2378 + ```bash 2379 + pnpm dev 2380 + ``` 2381 + 2382 + Open Bruno, run each request, verify responses match documentation. 2383 + 2384 + **Step 7: Commit** 2385 + 2386 + ```bash 2387 + git add bruno/ 2388 + git commit -m "docs(bruno): add board endpoints and update topic creation 2389 + 2390 + - List All Boards: GET /api/boards 2391 + - Get Board Topics: GET /api/boards/:id/topics 2392 + - Get Category Boards: GET /api/categories/:id/boards 2393 + - Update Create Topic to require boardUri 2394 + - Add board_rkey environment variable" 2395 + ``` 2396 + 2397 + --- 2398 + 2399 + ## Phase 6: Documentation & Cleanup 2400 + 2401 + ### Task 16: Update Project Plan 2402 + 2403 + **Files:** 2404 + - Modify: `docs/atproto-forum-plan.md` 2405 + 2406 + **Step 1: Mark ATB-23 complete** 2407 + 2408 + Find ATB-23 in the plan document and mark it complete: 2409 + 2410 + ```markdown 2411 + - [x] **ATB-23: Add categoryUri column to posts schema** (EXPANDED to boards hierarchy) 2412 + - Status: ✅ Complete (2026-02-13) 2413 + - Implemented 3-level hierarchy: Forum → Categories → Boards → Topics 2414 + - Added `space.atbb.forum.board` lexicon 2415 + - Posts link to both boards (primary) and forums (redundant) 2416 + - API endpoints: GET /api/boards, GET /api/boards/:id/topics, GET /api/categories/:id/boards 2417 + - POST /api/topics requires boardUri 2418 + - Files: See commit history for atb-23-boards-hierarchy branch 2419 + ``` 2420 + 2421 + **Step 2: Commit** 2422 + 2423 + ```bash 2424 + git add docs/atproto-forum-plan.md 2425 + git commit -m "docs: mark ATB-23 complete in project plan 2426 + 2427 + - Boards hierarchy implemented and tested 2428 + - All API endpoints functional 2429 + - Bruno collections updated" 2430 + ``` 2431 + 2432 + --- 2433 + 2434 + ### Task 17: Run Full Test Suite 2435 + 2436 + **Step 1: Run all tests** 2437 + 2438 + ```bash 2439 + pnpm test 2440 + ``` 2441 + 2442 + Expected: All tests pass across all packages 2443 + 2444 + **Step 2: If failures, debug and fix** 2445 + 2446 + If any tests fail: 2447 + 1. Read error messages carefully 2448 + 2. Fix the issue 2449 + 3. Re-run tests 2450 + 4. Commit fix with descriptive message 2451 + 2452 + **Step 3: Run build to verify types** 2453 + 2454 + ```bash 2455 + pnpm build 2456 + ``` 2457 + 2458 + Expected: Clean build, no TypeScript errors 2459 + 2460 + **Step 4: Verify no linting errors** 2461 + 2462 + ```bash 2463 + pnpm turbo lint 2464 + ``` 2465 + 2466 + Expected: No errors 2467 + 2468 + **Step 5: Document completion** 2469 + 2470 + If all checks pass, the implementation is complete! 2471 + 2472 + --- 2473 + 2474 + ## Success Criteria 2475 + 2476 + - ✅ All tests pass (pnpm test) 2477 + - ✅ Clean build (pnpm build) 2478 + - ✅ No lint errors (pnpm turbo lint) 2479 + - ✅ Bruno collections tested and working 2480 + - ✅ Database migrations applied 2481 + - ✅ Board indexing from firehose functional 2482 + - ✅ API endpoints return expected data 2483 + - ✅ POST /api/topics requires and validates boardUri 2484 + - ✅ Documentation updated 2485 + 2486 + ## Next Steps After Implementation 2487 + 2488 + 1. Update Linear issue ATB-23 status to Done 2489 + 2. Add implementation notes to Linear 2490 + 3. Create PR from `atb-23-boards-hierarchy` branch to `main` 2491 + 4. Request code review 2492 + 5. After approval, merge to main
+557
docs/plans/2026-02-13-boards-hierarchy-design.md
··· 1 + # atBB Boards Hierarchy Design 2 + 3 + **Issue:** ATB-23 - Add categoryUri column to posts schema 4 + **Date:** 2026-02-13 5 + **Status:** Approved 6 + 7 + ## Context 8 + 9 + The original ATB-23 scope was to add `categoryUri` to posts for category filtering. During design exploration, we discovered a fundamental mismatch between atBB's current 2-level hierarchy and traditional BB forum structure (phpBB, SMF). 10 + 11 + **Traditional BB forums use:** 12 + ``` 13 + Forum (instance) → Categories (groupings) → Boards (postable areas) → Topics → Replies 14 + ``` 15 + 16 + **atBB currently has:** 17 + ``` 18 + Forum (instance) → Categories (postable areas) → Topics → Replies 19 + ``` 20 + 21 + The current `categories` table is serving the role of "boards" (postable areas), not groupings. This restructuring aligns atBB with traditional forum UX expectations. 22 + 23 + ## Decision 24 + 25 + Restructure atBB to use the traditional 3-level hierarchy by introducing **boards** as the postable areas, with categories becoming non-postable groupings. 26 + 27 + **No migration needed:** There are no published records in production, only local development data. 28 + 29 + ## Data Model 30 + 31 + ### New Hierarchy 32 + 33 + ``` 34 + space.atbb.forum.forum # Forum instance (singleton, Forum DID) 35 + └─ space.atbb.forum.category # Category groupings (non-postable, Forum DID) 36 + └─ space.atbb.forum.board # Boards (postable areas, Forum DID) 37 + └─ space.atbb.post # Topics + replies (User DID) 38 + ``` 39 + 40 + ### Database Schema Changes 41 + 42 + **1. Keep `categories` table** (semantic shift to groupings): 43 + - No structural changes 44 + - Now represents non-postable category groupings 45 + 46 + **2. Create new `boards` table:** 47 + ```typescript 48 + export const boards = pgTable("boards", { 49 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 50 + did: text("did").notNull(), 51 + rkey: text("rkey").notNull(), 52 + cid: text("cid").notNull(), 53 + name: text("name").notNull(), 54 + description: text("description"), 55 + slug: text("slug"), 56 + sortOrder: integer("sort_order"), 57 + categoryId: bigint("category_id", { mode: "bigint" }).references(() => categories.id), 58 + categoryUri: text("category_uri").notNull(), // For out-of-order indexing 59 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 60 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 61 + }, (table) => [ 62 + uniqueIndex("boards_did_rkey_idx").on(table.did, table.rkey), 63 + index("boards_category_id_idx").on(table.categoryId), 64 + ]); 65 + ``` 66 + 67 + **3. Update `posts` table:** 68 + ```typescript 69 + export const posts = pgTable("posts", { 70 + // ... existing fields ... 71 + forumUri: text("forum_uri"), // KEEP - forum instance reference (for client flexibility) 72 + boardUri: text("board_uri"), // NEW - board reference 73 + boardId: bigint("board_id", { mode: "bigint" }).references(() => boards.id), // NEW 74 + rootPostId: bigint("root_post_id", { mode: "bigint" }).references((): any => posts.id), 75 + parentPostId: bigint("parent_post_id", { mode: "bigint" }).references((): any => posts.id), 76 + // ... rest of fields ... 77 + }, (table) => [ 78 + // ... existing indexes ... 79 + index("posts_board_id_idx").on(table.boardId), 80 + index("posts_board_uri_idx").on(table.boardUri), 81 + ]); 82 + ``` 83 + 84 + **Rationale for keeping `forumUri`:** 85 + - Maintains flexibility for clients with different UX models 86 + - Some clients may want flat view (forums → topics) without board hierarchy 87 + - Storage is cheap, redundancy aids AT Proto interoperability 88 + 89 + ## Lexicon Changes 90 + 91 + ### New Lexicon: `space.atbb.forum.board` 92 + 93 + ```yaml 94 + lexicon: 1 95 + id: space.atbb.forum.board 96 + defs: 97 + main: 98 + type: record 99 + description: A board (subforum) within a category. Owned by Forum DID. 100 + key: tid 101 + record: 102 + type: object 103 + required: [name, category, createdAt] 104 + properties: 105 + name: 106 + type: string 107 + maxLength: 300 108 + maxGraphemes: 100 109 + description: Display name of the board. 110 + description: 111 + type: string 112 + maxLength: 3000 113 + maxGraphemes: 300 114 + description: A short description for the board. 115 + slug: 116 + type: string 117 + maxLength: 100 118 + description: URL-friendly identifier. 119 + sortOrder: 120 + type: integer 121 + minimum: 0 122 + description: Numeric sort position. Lower values appear first. 123 + category: 124 + type: ref 125 + ref: "#categoryRef" 126 + createdAt: 127 + type: string 128 + format: datetime 129 + categoryRef: 130 + type: object 131 + required: [category] 132 + properties: 133 + category: 134 + type: ref 135 + ref: com.atproto.repo.strongRef 136 + ``` 137 + 138 + ### Updated Lexicon: `space.atbb.post` 139 + 140 + **Add `board` reference (keep existing `forum` reference):** 141 + 142 + ```yaml 143 + properties: 144 + text: ... 145 + forum: # KEEP - existing forum reference 146 + type: ref 147 + ref: "#forumRef" 148 + board: # NEW - board reference 149 + type: ref 150 + ref: "#boardRef" 151 + reply: ... 152 + createdAt: ... 153 + 154 + defs: 155 + # Existing forumRef stays unchanged 156 + forumRef: 157 + type: object 158 + required: [forum] 159 + properties: 160 + forum: 161 + type: ref 162 + ref: com.atproto.repo.strongRef 163 + 164 + # New boardRef 165 + boardRef: 166 + type: object 167 + required: [board] 168 + properties: 169 + board: 170 + type: ref 171 + ref: com.atproto.repo.strongRef 172 + ``` 173 + 174 + ## API Endpoints 175 + 176 + ### New Endpoints 177 + 178 + **Boards:** 179 + ``` 180 + GET /api/boards # List all boards (with category grouping) 181 + GET /api/boards/:id # Get board details 182 + GET /api/boards/:id/topics # List topics in a board (paginated by page number) 183 + ``` 184 + 185 + **Categories (enhanced):** 186 + ``` 187 + GET /api/categories # Existing - list all categories 188 + GET /api/categories/:id/boards # NEW - list boards in a category 189 + ``` 190 + 191 + ### Updated Endpoints 192 + 193 + **Topics:** 194 + ``` 195 + POST /api/topics 196 + Body: { text: string, boardUri: string } 197 + - boardUri is REQUIRED 198 + - forumUri is always the configured singleton (not accepted as input) 199 + - Validates board exists before writing to PDS 200 + - Writes both forum and board refs to PDS 201 + 202 + GET /api/topics/:id 203 + - No changes 204 + ``` 205 + 206 + ### Endpoint Details 207 + 208 + **`GET /api/boards/:id/topics`:** 209 + - Returns topics (posts with NULL root) in the board 210 + - Paginated using page numbers (traditional BB forum style) 211 + - Sorted by last reply time (descending) 212 + - Includes metadata: reply count, last post time, author info 213 + 214 + **`GET /api/categories/:id/boards`:** 215 + - Returns boards in a category 216 + - Sorted by `sortOrder` 217 + - Includes board metadata + topic count (e.g., "25 topics, 143 posts") 218 + 219 + **`GET /api/boards`:** 220 + - Returns all boards grouped by category 221 + - Includes category metadata and board counts 222 + 223 + ## Indexer Changes 224 + 225 + ### New Board Config 226 + 227 + ```typescript 228 + private boardConfig: CollectionConfig<Board.Record> = { 229 + name: "Board", 230 + table: boards, 231 + deleteStrategy: "hard", 232 + toInsertValues: async (event, record, tx) => { 233 + const categoryId = await this.getCategoryIdByUri(record.category.category.uri, tx); 234 + 235 + if (!categoryId) { 236 + console.warn(`[CREATE] Board: Category not found for URI ${record.category.category.uri}`); 237 + return null; 238 + } 239 + 240 + return { 241 + did: event.did, 242 + rkey: event.commit.rkey, 243 + cid: event.commit.cid, 244 + name: record.name, 245 + description: record.description ?? null, 246 + slug: record.slug ?? null, 247 + sortOrder: record.sortOrder ?? null, 248 + categoryId, 249 + categoryUri: record.category.category.uri, 250 + createdAt: new Date(record.createdAt), 251 + indexedAt: new Date(), 252 + }; 253 + }, 254 + toUpdateValues: async (event, record, tx) => { 255 + const categoryId = await this.getCategoryIdByUri(record.category.category.uri, tx); 256 + 257 + return { 258 + cid: event.commit.cid, 259 + name: record.name, 260 + description: record.description ?? null, 261 + slug: record.slug ?? null, 262 + sortOrder: record.sortOrder ?? null, 263 + categoryId, 264 + categoryUri: record.category.category.uri, 265 + indexedAt: new Date(), 266 + }; 267 + }, 268 + }; 269 + ``` 270 + 271 + ### Updated Post Config 272 + 273 + ```typescript 274 + private postConfig: CollectionConfig<Post.Record> = { 275 + // ... existing config ... 276 + toInsertValues: async (event, record, tx) => { 277 + // Look up board for the post 278 + let boardId: bigint | null = null; 279 + if (record.board) { 280 + boardId = await this.getBoardIdByUri(record.board.board.uri, tx); 281 + } 282 + 283 + // Look up parent/root for replies (existing logic) 284 + let rootId: bigint | null = null; 285 + let parentId: bigint | null = null; 286 + if (Post.isReplyRef(record.reply)) { 287 + rootId = await this.getPostIdByUri(record.reply.root.uri, tx); 288 + parentId = await this.getPostIdByUri(record.reply.parent.uri, tx); 289 + } 290 + 291 + return { 292 + did: event.did, 293 + rkey: event.commit.rkey, 294 + cid: event.commit.cid, 295 + text: record.text, 296 + forumUri: record.forum?.forum.uri ?? null, // KEEP 297 + boardUri: record.board?.board.uri ?? null, // NEW 298 + boardId, // NEW 299 + rootPostId: rootId, 300 + rootUri: record.reply?.root.uri ?? null, 301 + parentPostId: parentId, 302 + parentUri: record.reply?.parent.uri ?? null, 303 + createdAt: new Date(record.createdAt), 304 + indexedAt: new Date(), 305 + }; 306 + }, 307 + toUpdateValues: async (event, record) => ({ 308 + cid: event.commit.cid, 309 + text: record.text, 310 + forumUri: record.forum?.forum.uri ?? null, // KEEP 311 + boardUri: record.board?.board.uri ?? null, // NEW 312 + indexedAt: new Date(), 313 + }), 314 + }; 315 + ``` 316 + 317 + ### New Helper Methods 318 + 319 + ```typescript 320 + private async getBoardIdByUri(uri: string, tx: DbOrTransaction): Promise<bigint | null> { 321 + const { did, rkey } = parseAtUri(uri); 322 + const [result] = await tx 323 + .select({ id: boards.id }) 324 + .from(boards) 325 + .where(and(eq(boards.did, did), eq(boards.rkey, rkey))) 326 + .limit(1); 327 + return result?.id ?? null; 328 + } 329 + 330 + private async getCategoryIdByUri(uri: string, tx: DbOrTransaction): Promise<bigint | null> { 331 + const { did, rkey } = parseAtUri(uri); 332 + const [result] = await tx 333 + .select({ id: categories.id }) 334 + .from(categories) 335 + .where(and(eq(categories.did, did), eq(categories.rkey, rkey))) 336 + .limit(1); 337 + return result?.id ?? null; 338 + } 339 + ``` 340 + 341 + ### FirehoseService Registration 342 + 343 + ```typescript 344 + .register({ 345 + collection: "space.atbb.forum.board", 346 + onCreate: this.createWrappedHandler("handleBoardCreate"), 347 + onUpdate: this.createWrappedHandler("handleBoardUpdate"), 348 + onDelete: this.createWrappedHandler("handleBoardDelete"), 349 + }) 350 + ``` 351 + 352 + ## Write-Path Implementation 353 + 354 + ### Updated `POST /api/topics` 355 + 356 + ```typescript 357 + .post("/", requireAuth(ctx), async (c) => { 358 + const user = c.get("user")!; 359 + 360 + let body: any; 361 + try { 362 + body = await c.req.json(); 363 + } catch { 364 + return c.json({ error: "Invalid JSON in request body" }, 400); 365 + } 366 + 367 + const { text, boardUri } = body; 368 + 369 + // Validate text 370 + const validation = validatePostText(text); 371 + if (!validation.valid) { 372 + return c.json({ error: validation.error }, 400); 373 + } 374 + 375 + // Validate boardUri is required 376 + if (typeof boardUri !== "string" || !boardUri.trim()) { 377 + return c.json({ error: "boardUri is required" }, 400); 378 + } 379 + 380 + try { 381 + // Always use the configured singleton forum 382 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 383 + 384 + // Look up forum to get CID 385 + const forum = await getForumByUri(ctx.db, forumUri); 386 + if (!forum) { 387 + return c.json({ error: "Forum not found" }, 404); 388 + } 389 + 390 + // Look up board to get CID 391 + const board = await getBoardByUri(ctx.db, boardUri); 392 + if (!board) { 393 + return c.json({ error: "Board not found" }, 404); 394 + } 395 + 396 + // Generate TID for rkey 397 + const rkey = TID.nextStr(); 398 + 399 + // Write to user's PDS 400 + const result = await user.agent.com.atproto.repo.putRecord({ 401 + repo: user.did, 402 + collection: "space.atbb.post", 403 + rkey, 404 + record: { 405 + $type: "space.atbb.post", 406 + text: validation.trimmed!, 407 + forum: { 408 + forum: { uri: forumUri, cid: forum.cid }, 409 + }, 410 + board: { 411 + board: { uri: boardUri, cid: board.cid }, 412 + }, 413 + createdAt: new Date().toISOString(), 414 + }, 415 + }); 416 + 417 + return c.json({ uri: result.data.uri, cid: result.data.cid, rkey }, 201); 418 + } catch (error) { 419 + // ... existing error handling ... 420 + } 421 + }); 422 + ``` 423 + 424 + ### New Helper Function 425 + 426 + ```typescript 427 + export async function getBoardByUri( 428 + db: Database, 429 + uri: string 430 + ): Promise<{ cid: string } | null> { 431 + const { did, rkey } = parseAtUri(uri); 432 + const [result] = await db 433 + .select({ cid: boards.cid }) 434 + .from(boards) 435 + .where(and(eq(boards.did, did), eq(boards.rkey, rkey))) 436 + .limit(1); 437 + return result ?? null; 438 + } 439 + ``` 440 + 441 + ## Database Migrations 442 + 443 + **Migration 1: Create boards table** 444 + ```sql 445 + CREATE TABLE boards ( 446 + id BIGSERIAL PRIMARY KEY, 447 + did TEXT NOT NULL, 448 + rkey TEXT NOT NULL, 449 + cid TEXT NOT NULL, 450 + name TEXT NOT NULL, 451 + description TEXT, 452 + slug TEXT, 453 + sort_order INTEGER, 454 + category_id BIGINT REFERENCES categories(id), 455 + category_uri TEXT NOT NULL, 456 + created_at TIMESTAMPTZ NOT NULL, 457 + indexed_at TIMESTAMPTZ NOT NULL 458 + ); 459 + 460 + CREATE UNIQUE INDEX boards_did_rkey_idx ON boards(did, rkey); 461 + CREATE INDEX boards_category_id_idx ON boards(category_id); 462 + ``` 463 + 464 + **Migration 2: Update posts table** 465 + ```sql 466 + ALTER TABLE posts ADD COLUMN board_uri TEXT; 467 + ALTER TABLE posts ADD COLUMN board_id BIGINT REFERENCES boards(id); 468 + 469 + CREATE INDEX posts_board_id_idx ON posts(board_id); 470 + CREATE INDEX posts_board_uri_idx ON posts(board_uri); 471 + ``` 472 + 473 + ## Testing Strategy 474 + 475 + ### Test Coverage 476 + 477 + **Lexicon:** 478 + - ✅ `space.atbb.forum.board` generates correct TypeScript types 479 + - ✅ Post type includes both `forum` and `board` refs 480 + 481 + **Indexer:** 482 + - ✅ Board create/update/delete events 483 + - ✅ Posts with board references get indexed correctly 484 + - ✅ Handles missing board gracefully (logs warning, skips insert) 485 + - ✅ Category references resolve correctly 486 + 487 + **API Endpoints:** 488 + - ✅ `GET /api/boards` returns all boards grouped by category 489 + - ✅ `GET /api/boards/:id` returns board details 490 + - ✅ `GET /api/boards/:id/topics` returns paginated topics with metadata (reply count, last post time, author) 491 + - ✅ `GET /api/categories/:id/boards` returns boards in category with topic counts 492 + - ✅ `POST /api/topics` requires boardUri, validates board exists 493 + - ✅ `POST /api/topics` writes both forum and board refs to PDS 494 + 495 + **Error Cases:** 496 + - ✅ Invalid boardUri format (400) 497 + - ✅ Board not found (404) 498 + - ✅ Missing boardUri in POST request (400) 499 + - ✅ Malformed JSON in POST request (400) 500 + 501 + **Integration:** 502 + - ✅ End-to-end: Create board → Create topic → View in board → Reply to topic 503 + - ✅ Pagination works correctly with page numbers 504 + - ✅ Topic counts update correctly 505 + 506 + ## Implementation Phases 507 + 508 + **Phase 1: Lexicon & Schema** 509 + 1. Create `space.atbb.forum.board` lexicon 510 + 2. Update `space.atbb.post` lexicon with board reference 511 + 3. Regenerate TypeScript types 512 + 4. Create database migration for boards table 513 + 5. Create database migration for posts table updates 514 + 515 + **Phase 2: Indexer** 516 + 1. Add board config to Indexer 517 + 2. Update post config to extract boardUri 518 + 3. Add helper methods (getBoardIdByUri, getCategoryIdByUri) 519 + 4. Register board handlers in FirehoseService 520 + 5. Write indexer tests 521 + 522 + **Phase 3: API - Boards** 523 + 1. Create `GET /api/boards` endpoint 524 + 2. Create `GET /api/boards/:id` endpoint 525 + 3. Create `GET /api/boards/:id/topics` endpoint with pagination 526 + 4. Create `GET /api/categories/:id/boards` endpoint 527 + 5. Write API tests 528 + 529 + **Phase 4: API - Topics** 530 + 1. Update `POST /api/topics` to require boardUri 531 + 2. Add getBoardByUri helper 532 + 3. Update request validation 533 + 4. Update PDS write to include board reference 534 + 5. Write API tests 535 + 536 + **Phase 5: Bruno Collections** 537 + 1. Add board endpoints to Bruno collection 538 + 2. Update topic creation request with boardUri 539 + 3. Document all request/response formats 540 + 4. Add error case examples 541 + 542 + ## Success Criteria 543 + 544 + - ✅ Boards can be created and indexed from firehose 545 + - ✅ Topics require board assignment 546 + - ✅ API returns proper hierarchy: categories → boards → topics 547 + - ✅ Pagination works with page numbers 548 + - ✅ Topic metadata (reply count, last post) displays correctly 549 + - ✅ All tests pass 550 + - ✅ Bruno collections updated and tested 551 + - ✅ Documentation updated (plan doc, Linear issue) 552 + 553 + ## Related Issues 554 + 555 + - **ATB-23:** Original issue (scope expanded) 556 + - **ATB-11:** Where missing categoryUri was first identified 557 + - **Future:** Setup wizard to create initial forum/category/board structure