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(plans): move ATB-44 and ATB-45 plan docs to complete/

+2435
+1108
docs/plans/complete/2026-02-28-atb-44-category-management-endpoints.md
··· 1 + # ATB-44: Admin Panel Category Management AppView Endpoints 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add `POST /api/admin/categories`, `PUT /api/admin/categories/:id`, and `DELETE /api/admin/categories/:id` to the AppView. 6 + 7 + **Architecture:** All mutations follow the PDS-first write pattern — validate input, do DB pre-flight checks (reads only), write to Forum DID's PDS via `ForumAgent`, return the AT URI + CID from the PDS response. The firehose indexer handles the DB row update asynchronously. No direct DB writes in the mutation path. 8 + 9 + **Tech Stack:** Hono, Drizzle ORM (postgres.js/libsql), `@atproto/api` (`AtpAgent`), `@atproto/common-web` (`TID`), Vitest, Bruno 10 + 11 + --- 12 + 13 + ## Background 14 + 15 + All code for these endpoints lives in `apps/appview/src/routes/admin.ts`. 16 + Tests live in `apps/appview/src/routes/__tests__/admin.test.ts`. 17 + Bruno docs live in `bruno/AppView API/Admin/`. 18 + 19 + ### Key helpers already in scope (admin.ts already imports these): 20 + - `safeParseJsonBody(c)` — parses JSON body, returns `{ body, error }` (400 on malformed JSON) 21 + - `getForumAgentOrError(ctx, c, operation)` — returns `{ agent, error }` (500 if null, 503 if unauthenticated) 22 + - `handleReadError(c, error, msg, ctx)` — classifies DB read errors (503 DB, 503 network, 500 other) 23 + - `handleWriteError(c, error, msg, ctx)` — classifies PDS write errors (503 network, 500 other) 24 + - `requireAuth(ctx)` middleware — returns 401 if not authenticated, sets `c.get("user")` 25 + - `requirePermission(ctx, permission)` middleware — returns 403 if user lacks permission 26 + 27 + ### Imports to ADD to admin.ts: 28 + ```typescript 29 + import { TID } from "@atproto/common-web"; 30 + import { parseBigIntParam } from "./helpers.js"; 31 + // Add to the @atbb/db import: categories, boards 32 + // count is already imported from drizzle-orm 33 + ``` 34 + 35 + ### The category lexicon (`space.atbb.forum.category`): 36 + ``` 37 + Required: name (string, max 100 graphemes), createdAt (datetime) 38 + Optional: description (string), slug (string), sortOrder (integer ≥ 0) 39 + ``` 40 + 41 + ### DB schema for categories (packages/db/src/schema.ts): 42 + ```typescript 43 + categories: { id: bigserial, did, rkey, cid, name, description, slug, sortOrder, forumId, createdAt, indexedAt } 44 + boards: { id: bigserial, did, rkey, cid, name, ..., categoryId (FK → categories.id), categoryUri } 45 + ``` 46 + 47 + ### Test mock setup pattern: 48 + ```typescript 49 + // In beforeEach — extend the ForumAgent mock to include deleteRecord 50 + let mockDeleteRecord: ReturnType<typeof vi.fn>; 51 + mockDeleteRecord = vi.fn().mockResolvedValue({}); 52 + 53 + ctx.forumAgent = { 54 + getAgent: () => ({ 55 + com: { 56 + atproto: { 57 + repo: { 58 + putRecord: mockPutRecord, 59 + deleteRecord: mockDeleteRecord, 60 + }, 61 + }, 62 + }, 63 + }), 64 + } as any; 65 + ``` 66 + 67 + ### How tests verify 401: 68 + Set `mockUser = null` before the request — the `requireAuth` mock returns 401 when `mockUser` is null. 69 + 70 + ### Run command: 71 + ```bash 72 + PATH=/path/to/monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 73 + ``` 74 + 75 + --- 76 + 77 + ## Task 1: Add failing tests for POST /api/admin/categories 78 + 79 + **Files:** 80 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 81 + 82 + **Step 1: Add a new `describe` block for the create endpoint** 83 + 84 + Add this after the final closing `});` of the `describe("GET /api/admin/members/me", ...)` block (before the final `});` that closes `describe.sequential("Admin Routes", ...)`): 85 + 86 + ```typescript 87 + describe("POST /api/admin/categories", () => { 88 + beforeEach(async () => { 89 + await ctx.cleanDatabase(); 90 + 91 + await ctx.db.insert(forums).values({ 92 + did: ctx.config.forumDid, 93 + rkey: "self", 94 + cid: "bafytest", 95 + name: "Test Forum", 96 + description: "A test forum", 97 + indexedAt: new Date(), 98 + }); 99 + 100 + mockUser = { did: "did:plc:test-admin" }; 101 + mockPutRecord.mockClear(); 102 + mockPutRecord.mockResolvedValue({ data: { uri: "at://did:plc:test-forum/space.atbb.forum.category/tid123", cid: "bafycategory" } }); 103 + }); 104 + 105 + it("creates category with valid body → 201 and putRecord called", async () => { 106 + const res = await app.request("/api/admin/categories", { 107 + method: "POST", 108 + headers: { "Content-Type": "application/json" }, 109 + body: JSON.stringify({ name: "General Discussion", description: "Talk about anything.", sortOrder: 1 }), 110 + }); 111 + 112 + expect(res.status).toBe(201); 113 + const data = await res.json(); 114 + expect(data.uri).toBeDefined(); 115 + expect(data.cid).toBeDefined(); 116 + expect(mockPutRecord).toHaveBeenCalledWith( 117 + expect.objectContaining({ 118 + repo: ctx.config.forumDid, 119 + collection: "space.atbb.forum.category", 120 + record: expect.objectContaining({ 121 + $type: "space.atbb.forum.category", 122 + name: "General Discussion", 123 + description: "Talk about anything.", 124 + sortOrder: 1, 125 + createdAt: expect.any(String), 126 + }), 127 + }) 128 + ); 129 + }); 130 + 131 + it("creates category without optional fields → 201", async () => { 132 + const res = await app.request("/api/admin/categories", { 133 + method: "POST", 134 + headers: { "Content-Type": "application/json" }, 135 + body: JSON.stringify({ name: "Minimal" }), 136 + }); 137 + 138 + expect(res.status).toBe(201); 139 + expect(mockPutRecord).toHaveBeenCalledWith( 140 + expect.objectContaining({ 141 + record: expect.objectContaining({ name: "Minimal" }), 142 + }) 143 + ); 144 + }); 145 + 146 + it("returns 400 when name is missing → no PDS write", async () => { 147 + const res = await app.request("/api/admin/categories", { 148 + method: "POST", 149 + headers: { "Content-Type": "application/json" }, 150 + body: JSON.stringify({ description: "No name field" }), 151 + }); 152 + 153 + expect(res.status).toBe(400); 154 + const data = await res.json(); 155 + expect(data.error).toContain("name"); 156 + expect(mockPutRecord).not.toHaveBeenCalled(); 157 + }); 158 + 159 + it("returns 400 when name is empty string → no PDS write", async () => { 160 + const res = await app.request("/api/admin/categories", { 161 + method: "POST", 162 + headers: { "Content-Type": "application/json" }, 163 + body: JSON.stringify({ name: " " }), 164 + }); 165 + 166 + expect(res.status).toBe(400); 167 + expect(mockPutRecord).not.toHaveBeenCalled(); 168 + }); 169 + 170 + it("returns 400 for malformed JSON", async () => { 171 + const res = await app.request("/api/admin/categories", { 172 + method: "POST", 173 + headers: { "Content-Type": "application/json" }, 174 + body: "{ bad json }", 175 + }); 176 + 177 + expect(res.status).toBe(400); 178 + const data = await res.json(); 179 + expect(data.error).toContain("Invalid JSON"); 180 + expect(mockPutRecord).not.toHaveBeenCalled(); 181 + }); 182 + 183 + it("returns 401 when unauthenticated → no PDS write", async () => { 184 + mockUser = null; 185 + 186 + const res = await app.request("/api/admin/categories", { 187 + method: "POST", 188 + headers: { "Content-Type": "application/json" }, 189 + body: JSON.stringify({ name: "Test" }), 190 + }); 191 + 192 + expect(res.status).toBe(401); 193 + expect(mockPutRecord).not.toHaveBeenCalled(); 194 + }); 195 + 196 + it("returns 503 when PDS network error", async () => { 197 + mockPutRecord.mockRejectedValue(new Error("fetch failed")); 198 + 199 + const res = await app.request("/api/admin/categories", { 200 + method: "POST", 201 + headers: { "Content-Type": "application/json" }, 202 + body: JSON.stringify({ name: "Test" }), 203 + }); 204 + 205 + expect(res.status).toBe(503); 206 + const data = await res.json(); 207 + expect(data.error).toContain("Unable to reach external service"); 208 + }); 209 + 210 + it("returns 500 when ForumAgent unavailable", async () => { 211 + ctx.forumAgent = null; 212 + 213 + const res = await app.request("/api/admin/categories", { 214 + method: "POST", 215 + headers: { "Content-Type": "application/json" }, 216 + body: JSON.stringify({ name: "Test" }), 217 + }); 218 + 219 + expect(res.status).toBe(500); 220 + const data = await res.json(); 221 + expect(data.error).toContain("Forum agent not available"); 222 + }); 223 + }); 224 + ``` 225 + 226 + **Step 2: Run tests to confirm they fail** 227 + 228 + ```bash 229 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30 230 + ``` 231 + 232 + Expected: `POST /api/admin/categories` tests fail with 404 (route not found). 233 + 234 + --- 235 + 236 + ## Task 2: Implement POST /api/admin/categories 237 + 238 + **Files:** 239 + - Modify: `apps/appview/src/routes/admin.ts` 240 + 241 + **Step 1: Add missing imports at the top of admin.ts** 242 + 243 + The current import line is: 244 + ```typescript 245 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors } from "@atbb/db"; 246 + ``` 247 + 248 + Change it to: 249 + ```typescript 250 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards } from "@atbb/db"; 251 + ``` 252 + 253 + Also add at the top of the file (after existing imports): 254 + ```typescript 255 + import { TID } from "@atproto/common-web"; 256 + import { parseBigIntParam } from "./helpers.js"; 257 + ``` 258 + 259 + **Step 2: Add the POST /api/admin/categories handler** 260 + 261 + Add the following inside the `createAdminRoutes` function, before the `return app;` line: 262 + 263 + ```typescript 264 + /** 265 + * POST /api/admin/categories 266 + * 267 + * Create a new forum category. Writes space.atbb.forum.category to Forum DID's PDS. 268 + * The firehose indexer creates the DB row asynchronously. 269 + */ 270 + app.post( 271 + "/categories", 272 + requireAuth(ctx), 273 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 274 + async (c) => { 275 + const { body, error: parseError } = await safeParseJsonBody(c); 276 + if (parseError) return parseError; 277 + 278 + const { name, description, sortOrder } = body; 279 + 280 + if (typeof name !== "string" || name.trim().length === 0) { 281 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 282 + } 283 + 284 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/categories"); 285 + if (agentError) return agentError; 286 + 287 + const rkey = TID.nextStr(); 288 + const now = new Date().toISOString(); 289 + 290 + try { 291 + const result = await agent.com.atproto.repo.putRecord({ 292 + repo: ctx.config.forumDid, 293 + collection: "space.atbb.forum.category", 294 + rkey, 295 + record: { 296 + $type: "space.atbb.forum.category", 297 + name: name.trim(), 298 + ...(typeof description === "string" && { description: description.trim() }), 299 + ...(typeof sortOrder === "number" && { sortOrder }), 300 + createdAt: now, 301 + }, 302 + }); 303 + 304 + return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 305 + } catch (error) { 306 + return handleWriteError(c, error, "Failed to create category", { 307 + operation: "POST /api/admin/categories", 308 + logger: ctx.logger, 309 + }); 310 + } 311 + } 312 + ); 313 + ``` 314 + 315 + **Step 3: Run tests** 316 + 317 + ```bash 318 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30 319 + ``` 320 + 321 + Expected: All `POST /api/admin/categories` tests pass. 322 + 323 + **Step 4: Commit** 324 + 325 + ```bash 326 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 327 + git commit -m "feat(appview): POST /api/admin/categories create endpoint (ATB-44)" 328 + ``` 329 + 330 + --- 331 + 332 + ## Task 3: Add failing tests for PUT /api/admin/categories/:id 333 + 334 + **Files:** 335 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 336 + 337 + **Step 1: Add the `describe` block** 338 + 339 + Add a new `describe` block (after the POST block, before the final `});`). Note that we need `categories` in scope — add it to the import at line 5: 340 + 341 + ```typescript 342 + import { memberships, roles, rolePermissions, users, forums, categories } from "@atbb/db"; 343 + ``` 344 + 345 + Then add: 346 + 347 + ```typescript 348 + describe.sequential("PUT /api/admin/categories/:id", () => { 349 + let categoryId: string; 350 + 351 + beforeEach(async () => { 352 + await ctx.cleanDatabase(); 353 + 354 + await ctx.db.insert(forums).values({ 355 + did: ctx.config.forumDid, 356 + rkey: "self", 357 + cid: "bafytest", 358 + name: "Test Forum", 359 + description: "A test forum", 360 + indexedAt: new Date(), 361 + }); 362 + 363 + const [cat] = await ctx.db.insert(categories).values({ 364 + did: ctx.config.forumDid, 365 + rkey: "tid-test-cat", 366 + cid: "bafycat", 367 + name: "Original Name", 368 + description: "Original description", 369 + sortOrder: 1, 370 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 371 + indexedAt: new Date(), 372 + }).returning({ id: categories.id }); 373 + 374 + categoryId = cat.id.toString(); 375 + 376 + mockUser = { did: "did:plc:test-admin" }; 377 + mockPutRecord.mockClear(); 378 + mockPutRecord.mockResolvedValue({ data: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`, cid: "bafynewcid" } }); 379 + }); 380 + 381 + it("updates category name → 200 and putRecord called with same rkey", async () => { 382 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 383 + method: "PUT", 384 + headers: { "Content-Type": "application/json" }, 385 + body: JSON.stringify({ name: "Updated Name", description: "New desc", sortOrder: 2 }), 386 + }); 387 + 388 + expect(res.status).toBe(200); 389 + const data = await res.json(); 390 + expect(data.uri).toBeDefined(); 391 + expect(data.cid).toBeDefined(); 392 + expect(mockPutRecord).toHaveBeenCalledWith( 393 + expect.objectContaining({ 394 + repo: ctx.config.forumDid, 395 + collection: "space.atbb.forum.category", 396 + rkey: "tid-test-cat", // same rkey as existing category 397 + record: expect.objectContaining({ 398 + $type: "space.atbb.forum.category", 399 + name: "Updated Name", 400 + description: "New desc", 401 + sortOrder: 2, 402 + }), 403 + }) 404 + ); 405 + }); 406 + 407 + it("preserves original createdAt on update", async () => { 408 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 409 + method: "PUT", 410 + headers: { "Content-Type": "application/json" }, 411 + body: JSON.stringify({ name: "Updated Name" }), 412 + }); 413 + 414 + expect(res.status).toBe(200); 415 + expect(mockPutRecord).toHaveBeenCalledWith( 416 + expect.objectContaining({ 417 + record: expect.objectContaining({ 418 + createdAt: "2026-01-01T00:00:00.000Z", 419 + }), 420 + }) 421 + ); 422 + }); 423 + 424 + it("returns 400 when name is missing", async () => { 425 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 426 + method: "PUT", 427 + headers: { "Content-Type": "application/json" }, 428 + body: JSON.stringify({ description: "No name" }), 429 + }); 430 + 431 + expect(res.status).toBe(400); 432 + expect(mockPutRecord).not.toHaveBeenCalled(); 433 + }); 434 + 435 + it("returns 400 for invalid category ID", async () => { 436 + const res = await app.request("/api/admin/categories/not-a-number", { 437 + method: "PUT", 438 + headers: { "Content-Type": "application/json" }, 439 + body: JSON.stringify({ name: "Test" }), 440 + }); 441 + 442 + expect(res.status).toBe(400); 443 + const data = await res.json(); 444 + expect(data.error).toContain("Invalid category ID"); 445 + expect(mockPutRecord).not.toHaveBeenCalled(); 446 + }); 447 + 448 + it("returns 404 when category not found", async () => { 449 + const res = await app.request("/api/admin/categories/99999", { 450 + method: "PUT", 451 + headers: { "Content-Type": "application/json" }, 452 + body: JSON.stringify({ name: "Test" }), 453 + }); 454 + 455 + expect(res.status).toBe(404); 456 + expect(mockPutRecord).not.toHaveBeenCalled(); 457 + }); 458 + 459 + it("returns 401 when unauthenticated", async () => { 460 + mockUser = null; 461 + 462 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 463 + method: "PUT", 464 + headers: { "Content-Type": "application/json" }, 465 + body: JSON.stringify({ name: "Test" }), 466 + }); 467 + 468 + expect(res.status).toBe(401); 469 + expect(mockPutRecord).not.toHaveBeenCalled(); 470 + }); 471 + 472 + it("returns 503 when PDS network error", async () => { 473 + mockPutRecord.mockRejectedValue(new Error("fetch failed")); 474 + 475 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 476 + method: "PUT", 477 + headers: { "Content-Type": "application/json" }, 478 + body: JSON.stringify({ name: "Test" }), 479 + }); 480 + 481 + expect(res.status).toBe(503); 482 + }); 483 + 484 + it("returns 500 when ForumAgent unavailable", async () => { 485 + ctx.forumAgent = null; 486 + 487 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 488 + method: "PUT", 489 + headers: { "Content-Type": "application/json" }, 490 + body: JSON.stringify({ name: "Test" }), 491 + }); 492 + 493 + expect(res.status).toBe(500); 494 + }); 495 + }); 496 + ``` 497 + 498 + **Step 2: Run tests to confirm they fail** 499 + 500 + ```bash 501 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30 502 + ``` 503 + 504 + Expected: PUT tests fail with 404 (route not found). 505 + 506 + --- 507 + 508 + ## Task 4: Implement PUT /api/admin/categories/:id 509 + 510 + **Files:** 511 + - Modify: `apps/appview/src/routes/admin.ts` 512 + 513 + **Step 1: Add the PUT /api/admin/categories/:id handler** 514 + 515 + Add the following inside `createAdminRoutes`, after the POST /categories handler and before `return app;`: 516 + 517 + ```typescript 518 + /** 519 + * PUT /api/admin/categories/:id 520 + * 521 + * Update an existing category. Fetches existing rkey from DB, calls putRecord 522 + * with updated fields preserving the original createdAt. 523 + */ 524 + app.put( 525 + "/categories/:id", 526 + requireAuth(ctx), 527 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 528 + async (c) => { 529 + const idParam = c.req.param("id"); 530 + const id = parseBigIntParam(idParam); 531 + if (id === null) { 532 + return c.json({ error: "Invalid category ID" }, 400); 533 + } 534 + 535 + const { body, error: parseError } = await safeParseJsonBody(c); 536 + if (parseError) return parseError; 537 + 538 + const { name, description, sortOrder } = body; 539 + 540 + if (typeof name !== "string" || name.trim().length === 0) { 541 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 542 + } 543 + 544 + let category: typeof categories.$inferSelect; 545 + try { 546 + const [row] = await ctx.db 547 + .select() 548 + .from(categories) 549 + .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid))) 550 + .limit(1); 551 + 552 + if (!row) { 553 + return c.json({ error: "Category not found" }, 404); 554 + } 555 + category = row; 556 + } catch (error) { 557 + return handleReadError(c, error, "Failed to look up category", { 558 + operation: "PUT /api/admin/categories/:id", 559 + logger: ctx.logger, 560 + id: idParam, 561 + }); 562 + } 563 + 564 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/categories/:id"); 565 + if (agentError) return agentError; 566 + 567 + try { 568 + const result = await agent.com.atproto.repo.putRecord({ 569 + repo: ctx.config.forumDid, 570 + collection: "space.atbb.forum.category", 571 + rkey: category.rkey, 572 + record: { 573 + $type: "space.atbb.forum.category", 574 + name: name.trim(), 575 + ...(typeof description === "string" && { description: description.trim() }), 576 + ...(typeof sortOrder === "number" && { sortOrder }), 577 + createdAt: category.createdAt.toISOString(), 578 + }, 579 + }); 580 + 581 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 582 + } catch (error) { 583 + return handleWriteError(c, error, "Failed to update category", { 584 + operation: "PUT /api/admin/categories/:id", 585 + logger: ctx.logger, 586 + id: idParam, 587 + }); 588 + } 589 + } 590 + ); 591 + ``` 592 + 593 + **Step 2: Run tests** 594 + 595 + ```bash 596 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30 597 + ``` 598 + 599 + Expected: All PUT tests pass. 600 + 601 + **Step 3: Commit** 602 + 603 + ```bash 604 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 605 + git commit -m "feat(appview): PUT /api/admin/categories/:id update endpoint (ATB-44)" 606 + ``` 607 + 608 + --- 609 + 610 + ## Task 5: Add failing tests for DELETE /api/admin/categories/:id 611 + 612 + **Files:** 613 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 614 + 615 + **Step 1: Add `boards` to the import at line 5** 616 + 617 + ```typescript 618 + import { memberships, roles, rolePermissions, users, forums, categories, boards } from "@atbb/db"; 619 + ``` 620 + 621 + **Step 2: Update the top-level `beforeEach` to include `mockDeleteRecord`** 622 + 623 + The existing outer `beforeEach` has: 624 + ```typescript 625 + mockPutRecord = vi.fn().mockResolvedValue({ uri: "at://...", cid: "bafytest" }); 626 + ``` 627 + 628 + Change the ForumAgent mock to also expose `deleteRecord`: 629 + ```typescript 630 + mockPutRecord = vi.fn().mockResolvedValue({ data: { uri: "at://...", cid: "bafytest" } }); 631 + const mockDeleteRecord = vi.fn().mockResolvedValue({}); 632 + 633 + ctx.forumAgent = { 634 + getAgent: () => ({ 635 + com: { 636 + atproto: { 637 + repo: { 638 + putRecord: mockPutRecord, 639 + deleteRecord: mockDeleteRecord, 640 + }, 641 + }, 642 + }, 643 + }), 644 + } as any; 645 + ``` 646 + 647 + Wait — `mockDeleteRecord` needs to be accessible inside the DELETE describe block. Promote it to module level alongside `mockPutRecord`: 648 + 649 + At module level (near line 10): 650 + ```typescript 651 + let mockDeleteRecord: ReturnType<typeof vi.fn>; 652 + ``` 653 + 654 + Then in the outer `beforeEach`: 655 + ```typescript 656 + mockDeleteRecord = vi.fn().mockResolvedValue({}); 657 + ``` 658 + 659 + And update the ForumAgent mock shape in the outer `beforeEach` to include it. 660 + 661 + **Step 3: Add the DELETE describe block** 662 + 663 + ```typescript 664 + describe.sequential("DELETE /api/admin/categories/:id", () => { 665 + let categoryId: string; 666 + 667 + beforeEach(async () => { 668 + await ctx.cleanDatabase(); 669 + 670 + await ctx.db.insert(forums).values({ 671 + did: ctx.config.forumDid, 672 + rkey: "self", 673 + cid: "bafytest", 674 + name: "Test Forum", 675 + description: "A test forum", 676 + indexedAt: new Date(), 677 + }); 678 + 679 + const [cat] = await ctx.db.insert(categories).values({ 680 + did: ctx.config.forumDid, 681 + rkey: "tid-test-del", 682 + cid: "bafycat", 683 + name: "Delete Me", 684 + description: null, 685 + sortOrder: 1, 686 + createdAt: new Date(), 687 + indexedAt: new Date(), 688 + }).returning({ id: categories.id }); 689 + 690 + categoryId = cat.id.toString(); 691 + 692 + mockUser = { did: "did:plc:test-admin" }; 693 + mockDeleteRecord.mockClear(); 694 + mockDeleteRecord.mockResolvedValue({}); 695 + }); 696 + 697 + it("deletes empty category → 200 and deleteRecord called", async () => { 698 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 699 + method: "DELETE", 700 + }); 701 + 702 + expect(res.status).toBe(200); 703 + const data = await res.json(); 704 + expect(data.success).toBe(true); 705 + expect(mockDeleteRecord).toHaveBeenCalledWith({ 706 + repo: ctx.config.forumDid, 707 + collection: "space.atbb.forum.category", 708 + rkey: "tid-test-del", 709 + }); 710 + }); 711 + 712 + it("returns 409 when category has boards → deleteRecord NOT called", async () => { 713 + // Insert a category for the board to reference 714 + const [cat] = await ctx.db.select({ id: categories.id }) 715 + .from(categories) 716 + .where(eq(categories.rkey, "tid-test-del")) 717 + .limit(1); 718 + 719 + await ctx.db.insert(boards).values({ 720 + did: ctx.config.forumDid, 721 + rkey: "tid-board-1", 722 + cid: "bafyboard", 723 + name: "Blocked Board", 724 + categoryId: cat.id, 725 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-del`, 726 + createdAt: new Date(), 727 + indexedAt: new Date(), 728 + }); 729 + 730 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 731 + method: "DELETE", 732 + }); 733 + 734 + expect(res.status).toBe(409); 735 + const data = await res.json(); 736 + expect(data.error).toContain("boards"); 737 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 738 + }); 739 + 740 + it("returns 400 for invalid category ID", async () => { 741 + const res = await app.request("/api/admin/categories/not-a-number", { 742 + method: "DELETE", 743 + }); 744 + 745 + expect(res.status).toBe(400); 746 + const data = await res.json(); 747 + expect(data.error).toContain("Invalid category ID"); 748 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 749 + }); 750 + 751 + it("returns 404 when category not found", async () => { 752 + const res = await app.request("/api/admin/categories/99999", { 753 + method: "DELETE", 754 + }); 755 + 756 + expect(res.status).toBe(404); 757 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 758 + }); 759 + 760 + it("returns 401 when unauthenticated", async () => { 761 + mockUser = null; 762 + 763 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 764 + method: "DELETE", 765 + }); 766 + 767 + expect(res.status).toBe(401); 768 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 769 + }); 770 + 771 + it("returns 503 when PDS network error on delete", async () => { 772 + mockDeleteRecord.mockRejectedValue(new Error("fetch failed")); 773 + 774 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 775 + method: "DELETE", 776 + }); 777 + 778 + expect(res.status).toBe(503); 779 + }); 780 + 781 + it("returns 500 when ForumAgent unavailable", async () => { 782 + ctx.forumAgent = null; 783 + 784 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 785 + method: "DELETE", 786 + }); 787 + 788 + expect(res.status).toBe(500); 789 + }); 790 + }); 791 + ``` 792 + 793 + **Step 4: Run tests to confirm they fail** 794 + 795 + ```bash 796 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30 797 + ``` 798 + 799 + Expected: DELETE tests fail with 404 (route not found). 800 + 801 + --- 802 + 803 + ## Task 6: Implement DELETE /api/admin/categories/:id 804 + 805 + **Files:** 806 + - Modify: `apps/appview/src/routes/admin.ts` 807 + 808 + **Step 1: Add the DELETE /api/admin/categories/:id handler** 809 + 810 + Add after the PUT handler, before `return app;`: 811 + 812 + ```typescript 813 + /** 814 + * DELETE /api/admin/categories/:id 815 + * 816 + * Delete a category. Pre-flight: refuses with 409 if any boards reference this 817 + * category in the DB. If clear, calls deleteRecord on the Forum DID's PDS. 818 + * The firehose indexer removes the DB row asynchronously. 819 + */ 820 + app.delete( 821 + "/categories/:id", 822 + requireAuth(ctx), 823 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 824 + async (c) => { 825 + const idParam = c.req.param("id"); 826 + const id = parseBigIntParam(idParam); 827 + if (id === null) { 828 + return c.json({ error: "Invalid category ID" }, 400); 829 + } 830 + 831 + let category: typeof categories.$inferSelect; 832 + try { 833 + const [row] = await ctx.db 834 + .select() 835 + .from(categories) 836 + .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid))) 837 + .limit(1); 838 + 839 + if (!row) { 840 + return c.json({ error: "Category not found" }, 404); 841 + } 842 + category = row; 843 + } catch (error) { 844 + return handleReadError(c, error, "Failed to look up category", { 845 + operation: "DELETE /api/admin/categories/:id", 846 + logger: ctx.logger, 847 + id: idParam, 848 + }); 849 + } 850 + 851 + // Pre-flight: refuse if any boards reference this category 852 + try { 853 + const [boardCount] = await ctx.db 854 + .select({ count: count() }) 855 + .from(boards) 856 + .where(eq(boards.categoryId, id)); 857 + 858 + if (boardCount && boardCount.count > 0) { 859 + return c.json( 860 + { error: "Cannot delete category with boards. Remove all boards first." }, 861 + 409 862 + ); 863 + } 864 + } catch (error) { 865 + return handleReadError(c, error, "Failed to check category boards", { 866 + operation: "DELETE /api/admin/categories/:id", 867 + logger: ctx.logger, 868 + id: idParam, 869 + }); 870 + } 871 + 872 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/categories/:id"); 873 + if (agentError) return agentError; 874 + 875 + try { 876 + await agent.com.atproto.repo.deleteRecord({ 877 + repo: ctx.config.forumDid, 878 + collection: "space.atbb.forum.category", 879 + rkey: category.rkey, 880 + }); 881 + 882 + return c.json({ success: true }); 883 + } catch (error) { 884 + return handleWriteError(c, error, "Failed to delete category", { 885 + operation: "DELETE /api/admin/categories/:id", 886 + logger: ctx.logger, 887 + id: idParam, 888 + }); 889 + } 890 + } 891 + ); 892 + ``` 893 + 894 + **Step 2: Run all tests** 895 + 896 + ```bash 897 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30 898 + ``` 899 + 900 + Expected: All tests pass. 901 + 902 + **Step 3: Run full test suite** 903 + 904 + ```bash 905 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview test 2>&1 | tail -20 906 + ``` 907 + 908 + Expected: All pass. 909 + 910 + **Step 4: Commit** 911 + 912 + ```bash 913 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 914 + git commit -m "feat(appview): DELETE /api/admin/categories/:id delete endpoint (ATB-44)" 915 + ``` 916 + 917 + --- 918 + 919 + ## Task 7: Add Bruno API collection files 920 + 921 + **Files:** 922 + - Create: `bruno/AppView API/Admin/Create Category.bru` 923 + - Create: `bruno/AppView API/Admin/Update Category.bru` 924 + - Create: `bruno/AppView API/Admin/Delete Category.bru` 925 + 926 + **Step 1: Create the three .bru files** 927 + 928 + `bruno/AppView API/Admin/Create Category.bru`: 929 + ``` 930 + meta { 931 + name: Create Category 932 + type: http 933 + seq: 10 934 + } 935 + 936 + post { 937 + url: {{appview_url}}/api/admin/categories 938 + } 939 + 940 + body:json { 941 + { 942 + "name": "General Discussion", 943 + "description": "Talk about anything.", 944 + "sortOrder": 1 945 + } 946 + } 947 + 948 + assert { 949 + res.status: eq 201 950 + res.body.uri: isDefined 951 + res.body.cid: isDefined 952 + } 953 + 954 + docs { 955 + Create a new forum category. Writes space.atbb.forum.category to the Forum DID's PDS. 956 + The firehose indexer creates the DB row asynchronously. 957 + 958 + **Requires:** space.atbb.permission.manageCategories 959 + 960 + Body: 961 + - name (required): Category display name 962 + - description (optional): Short description 963 + - sortOrder (optional): Numeric sort position (lower = first) 964 + 965 + Returns (201): 966 + { 967 + "uri": "at://did:plc:.../space.atbb.forum.category/abc123", 968 + "cid": "bafyrei..." 969 + } 970 + 971 + Error codes: 972 + - 400: Missing or empty name, malformed JSON 973 + - 401: Not authenticated 974 + - 403: Missing manageCategories permission 975 + - 500: ForumAgent not configured 976 + - 503: PDS network error 977 + } 978 + ``` 979 + 980 + `bruno/AppView API/Admin/Update Category.bru`: 981 + ``` 982 + meta { 983 + name: Update Category 984 + type: http 985 + seq: 11 986 + } 987 + 988 + put { 989 + url: {{appview_url}}/api/admin/categories/:id 990 + } 991 + 992 + params:path { 993 + id: {{category_id}} 994 + } 995 + 996 + body:json { 997 + { 998 + "name": "Updated Name", 999 + "description": "Updated description.", 1000 + "sortOrder": 2 1001 + } 1002 + } 1003 + 1004 + assert { 1005 + res.status: eq 200 1006 + res.body.uri: isDefined 1007 + res.body.cid: isDefined 1008 + } 1009 + 1010 + docs { 1011 + Update an existing forum category. Fetches existing rkey from DB, calls putRecord 1012 + with updated fields preserving the original createdAt. 1013 + 1014 + **Requires:** space.atbb.permission.manageCategories 1015 + 1016 + Path params: 1017 + - id: Category database ID (bigint as string) 1018 + 1019 + Body: 1020 + - name (required): New display name 1021 + - description (optional): New description 1022 + - sortOrder (optional): New sort position 1023 + 1024 + Returns (200): 1025 + { 1026 + "uri": "at://did:plc:.../space.atbb.forum.category/abc123", 1027 + "cid": "bafyrei..." 1028 + } 1029 + 1030 + Error codes: 1031 + - 400: Missing name, empty name, invalid ID format, malformed JSON 1032 + - 401: Not authenticated 1033 + - 403: Missing manageCategories permission 1034 + - 404: Category not found 1035 + - 500: ForumAgent not configured 1036 + - 503: PDS network error 1037 + } 1038 + ``` 1039 + 1040 + `bruno/AppView API/Admin/Delete Category.bru`: 1041 + ``` 1042 + meta { 1043 + name: Delete Category 1044 + type: http 1045 + seq: 12 1046 + } 1047 + 1048 + delete { 1049 + url: {{appview_url}}/api/admin/categories/:id 1050 + } 1051 + 1052 + params:path { 1053 + id: {{category_id}} 1054 + } 1055 + 1056 + assert { 1057 + res.status: eq 200 1058 + res.body.success: isTrue 1059 + } 1060 + 1061 + docs { 1062 + Delete a forum category. Pre-flight check refuses with 409 if any boards reference 1063 + this category. If clear, calls deleteRecord on the Forum DID's PDS. 1064 + The firehose indexer removes the DB row asynchronously. 1065 + 1066 + **Requires:** space.atbb.permission.manageCategories 1067 + 1068 + Path params: 1069 + - id: Category database ID (bigint as string) 1070 + 1071 + Returns (200): 1072 + { 1073 + "success": true 1074 + } 1075 + 1076 + Error codes: 1077 + - 400: Invalid ID format 1078 + - 401: Not authenticated 1079 + - 403: Missing manageCategories permission 1080 + - 404: Category not found 1081 + - 409: Category has boards — remove them first 1082 + - 500: ForumAgent not configured 1083 + - 503: PDS network error 1084 + } 1085 + ``` 1086 + 1087 + **Step 2: Commit** 1088 + 1089 + ```bash 1090 + git add "bruno/AppView API/Admin/Create Category.bru" \ 1091 + "bruno/AppView API/Admin/Update Category.bru" \ 1092 + "bruno/AppView API/Admin/Delete Category.bru" 1093 + git commit -m "docs(bruno): add category management API collection (ATB-44)" 1094 + ``` 1095 + 1096 + --- 1097 + 1098 + ## Final verification 1099 + 1100 + ```bash 1101 + # Run full test suite 1102 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview test 1103 + 1104 + # Lint fix 1105 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview lint:fix 1106 + ``` 1107 + 1108 + Then update Linear ATB-44 to Done and mark items complete in `docs/atproto-forum-plan.md`.
+1327
docs/plans/complete/2026-02-28-atb-45-board-management-endpoints.md
··· 1 + # Board Management Endpoints Implementation Plan (ATB-45) 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add `POST /api/admin/boards`, `PUT /api/admin/boards/:id`, and `DELETE /api/admin/boards/:id` endpoints to the AppView, following the same PDS-first write pattern as category management. 6 + 7 + **Architecture:** All mutations write to the Forum DID's PDS via `ForumAgent.putRecord()`/`deleteRecord()`; the firehose indexer handles DB updates asynchronously. The only synchronous DB read is looking up the category CID on create (needed to build the `categoryRef` strongRef required by the lexicon). Delete pre-flights against `posts.boardId` to refuse with 409 if posts exist. 8 + 9 + **Tech Stack:** Hono, Drizzle ORM (postgres.js), `@atproto/common-web` (TID generation), Vitest, Bruno. 10 + 11 + --- 12 + 13 + ## Key files to understand before starting 14 + 15 + - **Implementation:** `apps/appview/src/routes/admin.ts` — add the 3 new endpoints at the bottom (before `return app`) 16 + - **Tests:** `apps/appview/src/routes/__tests__/admin.test.ts` — add a new `describe.sequential("POST /api/admin/boards"...)` block after the categories tests (around line 1450) 17 + - **DB schema:** `packages/db/src/schema.ts` — `boards` table has `id`, `did`, `rkey`, `cid`, `name`, `description`, `slug`, `sortOrder`, `categoryId`, `categoryUri`, `createdAt`, `indexedAt` 18 + - **Lexicon:** `packages/lexicon/lexicons/space/atbb/forum/board.yaml` — record has `name`, `description`, `slug`, `sortOrder`, `category` (a `categoryRef` object: `{ category: { uri, cid } }`) 19 + - **Route helpers:** `apps/appview/src/lib/route-errors.ts` — `handleRouteError`, `safeParseJsonBody`, `getForumAgentOrError` 20 + - **Bruno templates:** `bruno/AppView API/Admin/Create Category.bru`, `Update Category.bru`, `Delete Category.bru` — copy as starting point 21 + 22 + ## Test setup pattern (copy from categories tests) 23 + 24 + The test file already has mocks at the top for `requireAuth`, `requirePermission`, `mockPutRecord`, and `mockDeleteRecord`. The board tests reuse these same mocks. No new setup needed — just add new `describe.sequential` blocks after the existing categories describe blocks. 25 + 26 + For tests that need a category in the DB (create + edit lookups), insert with: 27 + ```typescript 28 + const [cat] = await ctx.db.insert(categories).values({ 29 + did: ctx.config.forumDid, 30 + rkey: "tid-test-cat", 31 + cid: "bafycat", 32 + name: "Test Category", 33 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 34 + indexedAt: new Date(), 35 + }).returning({ id: categories.id }); 36 + const categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 37 + ``` 38 + 39 + For tests that need a board in the DB (edit + delete), insert with: 40 + ```typescript 41 + const [brd] = await ctx.db.insert(boards).values({ 42 + did: ctx.config.forumDid, 43 + rkey: "tid-test-board", 44 + cid: "bafyboard", 45 + name: "Original Name", 46 + description: "Original description", 47 + sortOrder: 1, 48 + categoryId: cat.id, 49 + categoryUri, 50 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 51 + indexedAt: new Date(), 52 + }).returning({ id: boards.id }); 53 + const boardId = brd.id.toString(); 54 + ``` 55 + 56 + --- 57 + 58 + ## Task 1: POST /api/admin/boards — failing tests 59 + 60 + **Files:** 61 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` (append after line ~1450, after the closing `});` of the DELETE categories describe block) 62 + 63 + **Step 1: Write the failing tests** 64 + 65 + Add this new describe block to the end of the `describe.sequential("Admin Routes", ...)` block (just before the outer closing `});`): 66 + 67 + ```typescript 68 + describe.sequential("POST /api/admin/boards", () => { 69 + let categoryUri: string; 70 + 71 + beforeEach(async () => { 72 + await ctx.cleanDatabase(); 73 + 74 + mockUser = { did: "did:plc:test-admin" }; 75 + mockPutRecord.mockClear(); 76 + mockDeleteRecord.mockClear(); 77 + mockPutRecord.mockResolvedValue({ 78 + data: { 79 + uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid123`, 80 + cid: "bafyboard", 81 + }, 82 + }); 83 + 84 + // Insert a category the tests can reference 85 + await ctx.db.insert(categories).values({ 86 + did: ctx.config.forumDid, 87 + rkey: "tid-test-cat", 88 + cid: "bafycat", 89 + name: "Test Category", 90 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 91 + indexedAt: new Date(), 92 + }); 93 + categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 94 + }); 95 + 96 + it("creates board with valid body → 201 and putRecord called with categoryRef", async () => { 97 + const res = await app.request("/api/admin/boards", { 98 + method: "POST", 99 + headers: { "Content-Type": "application/json" }, 100 + body: JSON.stringify({ name: "General Chat", description: "Talk here.", sortOrder: 1, categoryUri }), 101 + }); 102 + 103 + expect(res.status).toBe(201); 104 + const data = await res.json(); 105 + expect(data.uri).toContain("/space.atbb.forum.board/"); 106 + expect(data.cid).toBe("bafyboard"); 107 + expect(mockPutRecord).toHaveBeenCalledWith( 108 + expect.objectContaining({ 109 + repo: ctx.config.forumDid, 110 + collection: "space.atbb.forum.board", 111 + rkey: expect.any(String), 112 + record: expect.objectContaining({ 113 + $type: "space.atbb.forum.board", 114 + name: "General Chat", 115 + description: "Talk here.", 116 + sortOrder: 1, 117 + category: { category: { uri: categoryUri, cid: "bafycat" } }, 118 + createdAt: expect.any(String), 119 + }), 120 + }) 121 + ); 122 + }); 123 + 124 + it("creates board without optional fields → 201", async () => { 125 + const res = await app.request("/api/admin/boards", { 126 + method: "POST", 127 + headers: { "Content-Type": "application/json" }, 128 + body: JSON.stringify({ name: "Minimal", categoryUri }), 129 + }); 130 + 131 + expect(res.status).toBe(201); 132 + expect(mockPutRecord).toHaveBeenCalledWith( 133 + expect.objectContaining({ 134 + record: expect.objectContaining({ name: "Minimal" }), 135 + }) 136 + ); 137 + }); 138 + 139 + it("returns 400 when name is missing → no PDS write", async () => { 140 + const res = await app.request("/api/admin/boards", { 141 + method: "POST", 142 + headers: { "Content-Type": "application/json" }, 143 + body: JSON.stringify({ categoryUri }), 144 + }); 145 + 146 + expect(res.status).toBe(400); 147 + const data = await res.json(); 148 + expect(data.error).toContain("name"); 149 + expect(mockPutRecord).not.toHaveBeenCalled(); 150 + }); 151 + 152 + it("returns 400 when name is empty string → no PDS write", async () => { 153 + const res = await app.request("/api/admin/boards", { 154 + method: "POST", 155 + headers: { "Content-Type": "application/json" }, 156 + body: JSON.stringify({ name: " ", categoryUri }), 157 + }); 158 + 159 + expect(res.status).toBe(400); 160 + expect(mockPutRecord).not.toHaveBeenCalled(); 161 + }); 162 + 163 + it("returns 400 when categoryUri is missing → no PDS write", async () => { 164 + const res = await app.request("/api/admin/boards", { 165 + method: "POST", 166 + headers: { "Content-Type": "application/json" }, 167 + body: JSON.stringify({ name: "Test Board" }), 168 + }); 169 + 170 + expect(res.status).toBe(400); 171 + const data = await res.json(); 172 + expect(data.error).toContain("categoryUri"); 173 + expect(mockPutRecord).not.toHaveBeenCalled(); 174 + }); 175 + 176 + it("returns 404 when categoryUri references unknown category → no PDS write", async () => { 177 + const res = await app.request("/api/admin/boards", { 178 + method: "POST", 179 + headers: { "Content-Type": "application/json" }, 180 + body: JSON.stringify({ name: "Test Board", categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/unknown999` }), 181 + }); 182 + 183 + expect(res.status).toBe(404); 184 + const data = await res.json(); 185 + expect(data.error).toContain("Category not found"); 186 + expect(mockPutRecord).not.toHaveBeenCalled(); 187 + }); 188 + 189 + it("returns 400 for malformed JSON", async () => { 190 + const res = await app.request("/api/admin/boards", { 191 + method: "POST", 192 + headers: { "Content-Type": "application/json" }, 193 + body: "{ bad json }", 194 + }); 195 + 196 + expect(res.status).toBe(400); 197 + const data = await res.json(); 198 + expect(data.error).toContain("Invalid JSON"); 199 + expect(mockPutRecord).not.toHaveBeenCalled(); 200 + }); 201 + 202 + it("returns 401 when unauthenticated → no PDS write", async () => { 203 + mockUser = null; 204 + 205 + const res = await app.request("/api/admin/boards", { 206 + method: "POST", 207 + headers: { "Content-Type": "application/json" }, 208 + body: JSON.stringify({ name: "Test", categoryUri }), 209 + }); 210 + 211 + expect(res.status).toBe(401); 212 + expect(mockPutRecord).not.toHaveBeenCalled(); 213 + }); 214 + 215 + it("returns 503 when PDS network error", async () => { 216 + mockPutRecord.mockRejectedValue(new Error("fetch failed")); 217 + 218 + const res = await app.request("/api/admin/boards", { 219 + method: "POST", 220 + headers: { "Content-Type": "application/json" }, 221 + body: JSON.stringify({ name: "Test", categoryUri }), 222 + }); 223 + 224 + expect(res.status).toBe(503); 225 + const data = await res.json(); 226 + expect(data.error).toContain("Unable to reach external service"); 227 + expect(mockPutRecord).toHaveBeenCalled(); 228 + }); 229 + 230 + it("returns 500 when ForumAgent unavailable", async () => { 231 + ctx.forumAgent = null; 232 + 233 + const res = await app.request("/api/admin/boards", { 234 + method: "POST", 235 + headers: { "Content-Type": "application/json" }, 236 + body: JSON.stringify({ name: "Test", categoryUri }), 237 + }); 238 + 239 + expect(res.status).toBe(500); 240 + const data = await res.json(); 241 + expect(data.error).toContain("Forum agent not available"); 242 + }); 243 + 244 + it("returns 403 when user lacks manageCategories permission", async () => { 245 + const { requirePermission } = await import("../../middleware/permissions.js"); 246 + const mockRequirePermission = requirePermission as any; 247 + mockRequirePermission.mockImplementation(() => async (c: any) => { 248 + return c.json({ error: "Forbidden" }, 403); 249 + }); 250 + 251 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 252 + const res = await testApp.request("/api/admin/boards", { 253 + method: "POST", 254 + headers: { "Content-Type": "application/json" }, 255 + body: JSON.stringify({ name: "Test", categoryUri }), 256 + }); 257 + 258 + expect(res.status).toBe(403); 259 + expect(mockPutRecord).not.toHaveBeenCalled(); 260 + 261 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 262 + await next(); 263 + }); 264 + }); 265 + }); 266 + ``` 267 + 268 + **Step 2: Run tests to verify they fail** 269 + 270 + ```bash 271 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 272 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 273 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PASS|Error|✓|×|POST.*boards" | head -30 274 + ``` 275 + 276 + Expected: Tests fail with "Cannot find route" or similar. 277 + 278 + **Step 3: Commit the failing tests** 279 + 280 + ```bash 281 + git add apps/appview/src/routes/__tests__/admin.test.ts 282 + git commit -m "test(appview): add failing tests for POST /api/admin/boards (ATB-45)" 283 + ``` 284 + 285 + --- 286 + 287 + ## Task 2: POST /api/admin/boards — implementation 288 + 289 + **Files:** 290 + - Modify: `apps/appview/src/routes/admin.ts` (add before `return app;` at the end, around line 701) 291 + 292 + **Step 1: Add the POST /api/admin/boards endpoint** 293 + 294 + Insert the following handler before the `return app;` line: 295 + 296 + ```typescript 297 + /** 298 + * POST /api/admin/boards 299 + * 300 + * Create a new forum board within a category. Fetches the category's CID from DB 301 + * to build the categoryRef strongRef required by the lexicon. Writes 302 + * space.atbb.forum.board to the Forum DID's PDS via putRecord. 303 + * The firehose indexer creates the DB row asynchronously. 304 + */ 305 + app.post( 306 + "/boards", 307 + requireAuth(ctx), 308 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 309 + async (c) => { 310 + const { body, error: parseError } = await safeParseJsonBody(c); 311 + if (parseError) return parseError; 312 + 313 + const { name, description, sortOrder, categoryUri } = body; 314 + 315 + if (typeof name !== "string" || name.trim().length === 0) { 316 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 317 + } 318 + 319 + if (typeof categoryUri !== "string" || !categoryUri.startsWith("at://")) { 320 + return c.json({ error: "categoryUri is required and must be a valid AT URI" }, 400); 321 + } 322 + 323 + // Derive rkey from the categoryUri to look up the category in the DB 324 + const categoryRkey = categoryUri.split("/").pop(); 325 + 326 + let category: typeof categories.$inferSelect; 327 + try { 328 + const [row] = await ctx.db 329 + .select() 330 + .from(categories) 331 + .where( 332 + and( 333 + eq(categories.did, ctx.config.forumDid), 334 + eq(categories.rkey, categoryRkey ?? "") 335 + ) 336 + ) 337 + .limit(1); 338 + 339 + if (!row) { 340 + return c.json({ error: "Category not found" }, 404); 341 + } 342 + category = row; 343 + } catch (error) { 344 + return handleRouteError(c, error, "Failed to look up category", { 345 + operation: "POST /api/admin/boards", 346 + logger: ctx.logger, 347 + categoryUri, 348 + }); 349 + } 350 + 351 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/boards"); 352 + if (agentError) return agentError; 353 + 354 + const rkey = TID.nextStr(); 355 + const now = new Date().toISOString(); 356 + 357 + try { 358 + const result = await agent.com.atproto.repo.putRecord({ 359 + repo: ctx.config.forumDid, 360 + collection: "space.atbb.forum.board", 361 + rkey, 362 + record: { 363 + $type: "space.atbb.forum.board", 364 + name: name.trim(), 365 + ...(typeof description === "string" && { description: description.trim() }), 366 + ...(Number.isInteger(sortOrder) && sortOrder >= 0 && { sortOrder }), 367 + category: { category: { uri: categoryUri, cid: category.cid } }, 368 + createdAt: now, 369 + }, 370 + }); 371 + 372 + return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 373 + } catch (error) { 374 + return handleRouteError(c, error, "Failed to create board", { 375 + operation: "POST /api/admin/boards", 376 + logger: ctx.logger, 377 + categoryUri, 378 + }); 379 + } 380 + } 381 + ); 382 + ``` 383 + 384 + **Step 2: Run tests to verify they pass** 385 + 386 + ```bash 387 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 388 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 389 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PASS|POST.*boards|✓|×" | head -30 390 + ``` 391 + 392 + Expected: All POST /api/admin/boards tests PASS. 393 + 394 + **Step 3: Commit** 395 + 396 + ```bash 397 + git add apps/appview/src/routes/admin.ts 398 + git commit -m "feat(appview): POST /api/admin/boards create endpoint (ATB-45)" 399 + ``` 400 + 401 + --- 402 + 403 + ## Task 3: PUT /api/admin/boards/:id — failing tests 404 + 405 + **Files:** 406 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` (append after the POST boards describe block) 407 + 408 + **Step 1: Write the failing tests** 409 + 410 + ```typescript 411 + describe.sequential("PUT /api/admin/boards/:id", () => { 412 + let boardId: string; 413 + let categoryUri: string; 414 + 415 + beforeEach(async () => { 416 + await ctx.cleanDatabase(); 417 + 418 + mockUser = { did: "did:plc:test-admin" }; 419 + mockPutRecord.mockClear(); 420 + mockDeleteRecord.mockClear(); 421 + mockPutRecord.mockResolvedValue({ 422 + data: { 423 + uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`, 424 + cid: "bafyboardupdated", 425 + }, 426 + }); 427 + 428 + // Insert a category and a board 429 + const [cat] = await ctx.db.insert(categories).values({ 430 + did: ctx.config.forumDid, 431 + rkey: "tid-test-cat", 432 + cid: "bafycat", 433 + name: "Test Category", 434 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 435 + indexedAt: new Date(), 436 + }).returning({ id: categories.id }); 437 + 438 + categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 439 + 440 + const [brd] = await ctx.db.insert(boards).values({ 441 + did: ctx.config.forumDid, 442 + rkey: "tid-test-board", 443 + cid: "bafyboard", 444 + name: "Original Name", 445 + description: "Original description", 446 + sortOrder: 1, 447 + categoryId: cat.id, 448 + categoryUri, 449 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 450 + indexedAt: new Date(), 451 + }).returning({ id: boards.id }); 452 + 453 + boardId = brd.id.toString(); 454 + }); 455 + 456 + it("updates board with all fields → 200 and putRecord called with same rkey", async () => { 457 + const res = await app.request(`/api/admin/boards/${boardId}`, { 458 + method: "PUT", 459 + headers: { "Content-Type": "application/json" }, 460 + body: JSON.stringify({ name: "Renamed Board", description: "New description", sortOrder: 2 }), 461 + }); 462 + 463 + expect(res.status).toBe(200); 464 + const data = await res.json(); 465 + expect(data.uri).toContain("/space.atbb.forum.board/"); 466 + expect(data.cid).toBe("bafyboardupdated"); 467 + expect(mockPutRecord).toHaveBeenCalledWith( 468 + expect.objectContaining({ 469 + repo: ctx.config.forumDid, 470 + collection: "space.atbb.forum.board", 471 + rkey: "tid-test-board", 472 + record: expect.objectContaining({ 473 + $type: "space.atbb.forum.board", 474 + name: "Renamed Board", 475 + description: "New description", 476 + sortOrder: 2, 477 + category: { category: { uri: categoryUri, cid: "bafycat" } }, 478 + }), 479 + }) 480 + ); 481 + }); 482 + 483 + it("updates board without optional fields → falls back to existing values", async () => { 484 + const res = await app.request(`/api/admin/boards/${boardId}`, { 485 + method: "PUT", 486 + headers: { "Content-Type": "application/json" }, 487 + body: JSON.stringify({ name: "Renamed Only" }), 488 + }); 489 + 490 + expect(res.status).toBe(200); 491 + expect(mockPutRecord).toHaveBeenCalledWith( 492 + expect.objectContaining({ 493 + record: expect.objectContaining({ 494 + name: "Renamed Only", 495 + description: "Original description", 496 + sortOrder: 1, 497 + }), 498 + }) 499 + ); 500 + }); 501 + 502 + it("returns 400 when name is missing", async () => { 503 + const res = await app.request(`/api/admin/boards/${boardId}`, { 504 + method: "PUT", 505 + headers: { "Content-Type": "application/json" }, 506 + body: JSON.stringify({ description: "No name" }), 507 + }); 508 + 509 + expect(res.status).toBe(400); 510 + const data = await res.json(); 511 + expect(data.error).toContain("name"); 512 + expect(mockPutRecord).not.toHaveBeenCalled(); 513 + }); 514 + 515 + it("returns 400 when name is empty string", async () => { 516 + const res = await app.request(`/api/admin/boards/${boardId}`, { 517 + method: "PUT", 518 + headers: { "Content-Type": "application/json" }, 519 + body: JSON.stringify({ name: " " }), 520 + }); 521 + 522 + expect(res.status).toBe(400); 523 + expect(mockPutRecord).not.toHaveBeenCalled(); 524 + }); 525 + 526 + it("returns 400 for non-numeric ID", async () => { 527 + const res = await app.request("/api/admin/boards/not-a-number", { 528 + method: "PUT", 529 + headers: { "Content-Type": "application/json" }, 530 + body: JSON.stringify({ name: "Test" }), 531 + }); 532 + 533 + expect(res.status).toBe(400); 534 + expect(mockPutRecord).not.toHaveBeenCalled(); 535 + }); 536 + 537 + it("returns 404 when board not found", async () => { 538 + const res = await app.request("/api/admin/boards/99999", { 539 + method: "PUT", 540 + headers: { "Content-Type": "application/json" }, 541 + body: JSON.stringify({ name: "Test" }), 542 + }); 543 + 544 + expect(res.status).toBe(404); 545 + const data = await res.json(); 546 + expect(data.error).toContain("Board not found"); 547 + expect(mockPutRecord).not.toHaveBeenCalled(); 548 + }); 549 + 550 + it("returns 400 for malformed JSON", async () => { 551 + const res = await app.request(`/api/admin/boards/${boardId}`, { 552 + method: "PUT", 553 + headers: { "Content-Type": "application/json" }, 554 + body: "{ bad json }", 555 + }); 556 + 557 + expect(res.status).toBe(400); 558 + expect(mockPutRecord).not.toHaveBeenCalled(); 559 + }); 560 + 561 + it("returns 401 when unauthenticated", async () => { 562 + mockUser = null; 563 + 564 + const res = await app.request(`/api/admin/boards/${boardId}`, { 565 + method: "PUT", 566 + headers: { "Content-Type": "application/json" }, 567 + body: JSON.stringify({ name: "Test" }), 568 + }); 569 + 570 + expect(res.status).toBe(401); 571 + expect(mockPutRecord).not.toHaveBeenCalled(); 572 + }); 573 + 574 + it("returns 503 when PDS network error", async () => { 575 + mockPutRecord.mockRejectedValue(new Error("fetch failed")); 576 + 577 + const res = await app.request(`/api/admin/boards/${boardId}`, { 578 + method: "PUT", 579 + headers: { "Content-Type": "application/json" }, 580 + body: JSON.stringify({ name: "Test" }), 581 + }); 582 + 583 + expect(res.status).toBe(503); 584 + const data = await res.json(); 585 + expect(data.error).toContain("Unable to reach external service"); 586 + }); 587 + 588 + it("returns 500 when ForumAgent unavailable", async () => { 589 + ctx.forumAgent = null; 590 + 591 + const res = await app.request(`/api/admin/boards/${boardId}`, { 592 + method: "PUT", 593 + headers: { "Content-Type": "application/json" }, 594 + body: JSON.stringify({ name: "Test" }), 595 + }); 596 + 597 + expect(res.status).toBe(500); 598 + const data = await res.json(); 599 + expect(data.error).toContain("Forum agent not available"); 600 + }); 601 + 602 + it("returns 403 when user lacks manageCategories permission", async () => { 603 + const { requirePermission } = await import("../../middleware/permissions.js"); 604 + const mockRequirePermission = requirePermission as any; 605 + mockRequirePermission.mockImplementation(() => async (c: any) => { 606 + return c.json({ error: "Forbidden" }, 403); 607 + }); 608 + 609 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 610 + const res = await testApp.request(`/api/admin/boards/${boardId}`, { 611 + method: "PUT", 612 + headers: { "Content-Type": "application/json" }, 613 + body: JSON.stringify({ name: "Test" }), 614 + }); 615 + 616 + expect(res.status).toBe(403); 617 + expect(mockPutRecord).not.toHaveBeenCalled(); 618 + 619 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 620 + await next(); 621 + }); 622 + }); 623 + }); 624 + ``` 625 + 626 + **Step 2: Run tests to verify they fail** 627 + 628 + ```bash 629 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 630 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 631 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PUT.*boards|×" | head -20 632 + ``` 633 + 634 + Expected: PUT /api/admin/boards tests FAIL. 635 + 636 + **Step 3: Commit** 637 + 638 + ```bash 639 + git add apps/appview/src/routes/__tests__/admin.test.ts 640 + git commit -m "test(appview): add failing tests for PUT /api/admin/boards/:id (ATB-45)" 641 + ``` 642 + 643 + --- 644 + 645 + ## Task 4: PUT /api/admin/boards/:id — implementation 646 + 647 + **Files:** 648 + - Modify: `apps/appview/src/routes/admin.ts` (add after the POST /boards handler, before `return app;`) 649 + 650 + **Step 1: Add the PUT /api/admin/boards/:id endpoint** 651 + 652 + Note: The `boards` table stores `categoryUri` and `categoryId`. The edit endpoint re-uses the existing `categoryUri` and fetches the category CID. This avoids clients being able to secretly reparent a board by passing a new `categoryUri` on edit (category changes would need a dedicated reparent operation). 653 + 654 + ```typescript 655 + /** 656 + * PUT /api/admin/boards/:id 657 + * 658 + * Update an existing board's name, description, and sortOrder. 659 + * Fetches existing rkey + categoryUri from DB, then putRecord with updated fields. 660 + * Preserves the original categoryRef and createdAt. 661 + * The firehose indexer updates the DB row asynchronously. 662 + */ 663 + app.put( 664 + "/boards/:id", 665 + requireAuth(ctx), 666 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 667 + async (c) => { 668 + const idParam = c.req.param("id"); 669 + const id = parseBigIntParam(idParam); 670 + if (id === null) { 671 + return c.json({ error: "Invalid board ID" }, 400); 672 + } 673 + 674 + const { body, error: parseError } = await safeParseJsonBody(c); 675 + if (parseError) return parseError; 676 + 677 + const { name, description, sortOrder } = body; 678 + 679 + if (typeof name !== "string" || name.trim().length === 0) { 680 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 681 + } 682 + 683 + let board: typeof boards.$inferSelect; 684 + try { 685 + const [row] = await ctx.db 686 + .select() 687 + .from(boards) 688 + .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid))) 689 + .limit(1); 690 + 691 + if (!row) { 692 + return c.json({ error: "Board not found" }, 404); 693 + } 694 + board = row; 695 + } catch (error) { 696 + return handleRouteError(c, error, "Failed to look up board", { 697 + operation: "PUT /api/admin/boards/:id", 698 + logger: ctx.logger, 699 + id: idParam, 700 + }); 701 + } 702 + 703 + // Fetch category CID to build the categoryRef strongRef 704 + let categoryCid: string; 705 + try { 706 + const categoryRkey = board.categoryUri.split("/").pop() ?? ""; 707 + const [cat] = await ctx.db 708 + .select({ cid: categories.cid }) 709 + .from(categories) 710 + .where( 711 + and( 712 + eq(categories.did, ctx.config.forumDid), 713 + eq(categories.rkey, categoryRkey) 714 + ) 715 + ) 716 + .limit(1); 717 + 718 + if (!cat) { 719 + return c.json({ error: "Category not found" }, 404); 720 + } 721 + categoryCid = cat.cid; 722 + } catch (error) { 723 + return handleRouteError(c, error, "Failed to look up category", { 724 + operation: "PUT /api/admin/boards/:id", 725 + logger: ctx.logger, 726 + id: idParam, 727 + }); 728 + } 729 + 730 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/boards/:id"); 731 + if (agentError) return agentError; 732 + 733 + // putRecord is a full replacement — fall back to existing values for 734 + // optional fields not provided in the request body, to avoid data loss. 735 + const resolvedDescription = typeof description === "string" 736 + ? description.trim() 737 + : board.description; 738 + const resolvedSortOrder = (Number.isInteger(sortOrder) && sortOrder >= 0) 739 + ? sortOrder 740 + : board.sortOrder; 741 + 742 + try { 743 + const result = await agent.com.atproto.repo.putRecord({ 744 + repo: ctx.config.forumDid, 745 + collection: "space.atbb.forum.board", 746 + rkey: board.rkey, 747 + record: { 748 + $type: "space.atbb.forum.board", 749 + name: name.trim(), 750 + ...(resolvedDescription != null && { description: resolvedDescription }), 751 + ...(resolvedSortOrder != null && { sortOrder: resolvedSortOrder }), 752 + category: { category: { uri: board.categoryUri, cid: categoryCid } }, 753 + createdAt: board.createdAt.toISOString(), 754 + }, 755 + }); 756 + 757 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 758 + } catch (error) { 759 + return handleRouteError(c, error, "Failed to update board", { 760 + operation: "PUT /api/admin/boards/:id", 761 + logger: ctx.logger, 762 + id: idParam, 763 + }); 764 + } 765 + } 766 + ); 767 + ``` 768 + 769 + **Step 2: Run tests to verify they pass** 770 + 771 + ```bash 772 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 773 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 774 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PASS|PUT.*boards|✓|×" | head -30 775 + ``` 776 + 777 + Expected: All PUT /api/admin/boards tests PASS. 778 + 779 + **Step 3: Commit** 780 + 781 + ```bash 782 + git add apps/appview/src/routes/admin.ts 783 + git commit -m "feat(appview): PUT /api/admin/boards/:id update endpoint (ATB-45)" 784 + ``` 785 + 786 + --- 787 + 788 + ## Task 5: DELETE /api/admin/boards/:id — failing tests 789 + 790 + **Files:** 791 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` (append after the PUT boards describe block) 792 + 793 + **Step 1: Write the failing tests** 794 + 795 + ```typescript 796 + describe.sequential("DELETE /api/admin/boards/:id", () => { 797 + let boardId: string; 798 + let categoryUri: string; 799 + 800 + beforeEach(async () => { 801 + await ctx.cleanDatabase(); 802 + 803 + mockUser = { did: "did:plc:test-admin" }; 804 + mockPutRecord.mockClear(); 805 + mockDeleteRecord.mockClear(); 806 + mockDeleteRecord.mockResolvedValue({}); 807 + 808 + // Insert a category and a board 809 + const [cat] = await ctx.db.insert(categories).values({ 810 + did: ctx.config.forumDid, 811 + rkey: "tid-test-cat", 812 + cid: "bafycat", 813 + name: "Test Category", 814 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 815 + indexedAt: new Date(), 816 + }).returning({ id: categories.id }); 817 + 818 + categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 819 + 820 + const [brd] = await ctx.db.insert(boards).values({ 821 + did: ctx.config.forumDid, 822 + rkey: "tid-test-board", 823 + cid: "bafyboard", 824 + name: "Test Board", 825 + categoryId: cat.id, 826 + categoryUri, 827 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 828 + indexedAt: new Date(), 829 + }).returning({ id: boards.id }); 830 + 831 + boardId = brd.id.toString(); 832 + }); 833 + 834 + it("deletes empty board → 200 and deleteRecord called", async () => { 835 + const res = await app.request(`/api/admin/boards/${boardId}`, { 836 + method: "DELETE", 837 + }); 838 + 839 + expect(res.status).toBe(200); 840 + const data = await res.json(); 841 + expect(data.success).toBe(true); 842 + expect(mockDeleteRecord).toHaveBeenCalledWith( 843 + expect.objectContaining({ 844 + repo: ctx.config.forumDid, 845 + collection: "space.atbb.forum.board", 846 + rkey: "tid-test-board", 847 + }) 848 + ); 849 + }); 850 + 851 + it("returns 409 when board has posts → deleteRecord NOT called", async () => { 852 + // Insert a user and a post referencing this board 853 + await ctx.db.insert(users).values({ 854 + did: "did:plc:test-user", 855 + handle: "testuser.bsky.social", 856 + indexedAt: new Date(), 857 + }); 858 + 859 + const [brd] = await ctx.db.select().from(boards).where(eq(boards.rkey, "tid-test-board")).limit(1); 860 + 861 + // Insert a post with boardId set 862 + await ctx.db.insert(posts).values({ 863 + did: "did:plc:test-user", 864 + rkey: "tid-test-post", 865 + cid: "bafypost", 866 + text: "Hello world", 867 + boardId: brd.id, 868 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`, 869 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 870 + createdAt: new Date(), 871 + indexedAt: new Date(), 872 + }); 873 + 874 + const res = await app.request(`/api/admin/boards/${boardId}`, { 875 + method: "DELETE", 876 + }); 877 + 878 + expect(res.status).toBe(409); 879 + const data = await res.json(); 880 + expect(data.error).toContain("posts"); 881 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 882 + }); 883 + 884 + it("returns 400 for non-numeric ID", async () => { 885 + const res = await app.request("/api/admin/boards/not-a-number", { 886 + method: "DELETE", 887 + }); 888 + 889 + expect(res.status).toBe(400); 890 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 891 + }); 892 + 893 + it("returns 404 when board not found", async () => { 894 + const res = await app.request("/api/admin/boards/99999", { 895 + method: "DELETE", 896 + }); 897 + 898 + expect(res.status).toBe(404); 899 + const data = await res.json(); 900 + expect(data.error).toContain("Board not found"); 901 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 902 + }); 903 + 904 + it("returns 401 when unauthenticated", async () => { 905 + mockUser = null; 906 + 907 + const res = await app.request(`/api/admin/boards/${boardId}`, { 908 + method: "DELETE", 909 + }); 910 + 911 + expect(res.status).toBe(401); 912 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 913 + }); 914 + 915 + it("returns 503 when PDS network error", async () => { 916 + mockDeleteRecord.mockRejectedValue(new Error("fetch failed")); 917 + 918 + const res = await app.request(`/api/admin/boards/${boardId}`, { 919 + method: "DELETE", 920 + }); 921 + 922 + expect(res.status).toBe(503); 923 + const data = await res.json(); 924 + expect(data.error).toContain("Unable to reach external service"); 925 + }); 926 + 927 + it("returns 500 when ForumAgent unavailable", async () => { 928 + ctx.forumAgent = null; 929 + 930 + const res = await app.request(`/api/admin/boards/${boardId}`, { 931 + method: "DELETE", 932 + }); 933 + 934 + expect(res.status).toBe(500); 935 + const data = await res.json(); 936 + expect(data.error).toContain("Forum agent not available"); 937 + }); 938 + 939 + it("returns 403 when user lacks manageCategories permission", async () => { 940 + const { requirePermission } = await import("../../middleware/permissions.js"); 941 + const mockRequirePermission = requirePermission as any; 942 + mockRequirePermission.mockImplementation(() => async (c: any) => { 943 + return c.json({ error: "Forbidden" }, 403); 944 + }); 945 + 946 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 947 + const res = await testApp.request(`/api/admin/boards/${boardId}`, { 948 + method: "DELETE", 949 + }); 950 + 951 + expect(res.status).toBe(403); 952 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 953 + 954 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 955 + await next(); 956 + }); 957 + }); 958 + }); 959 + ``` 960 + 961 + **Important:** The DELETE test that inserts a post needs to import `posts` from `@atbb/db`. Check the import at the top of the test file — if `posts` is not already imported, add it to the existing import: 962 + 963 + ```typescript 964 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts } from "@atbb/db"; 965 + ``` 966 + 967 + **Step 2: Run tests to verify they fail** 968 + 969 + ```bash 970 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 971 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 972 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|DELETE.*boards|×" | head -20 973 + ``` 974 + 975 + Expected: DELETE /api/admin/boards tests FAIL. 976 + 977 + **Step 3: Commit** 978 + 979 + ```bash 980 + git add apps/appview/src/routes/__tests__/admin.test.ts 981 + git commit -m "test(appview): add failing tests for DELETE /api/admin/boards/:id (ATB-45)" 982 + ``` 983 + 984 + --- 985 + 986 + ## Task 6: DELETE /api/admin/boards/:id — implementation 987 + 988 + **Files:** 989 + - Modify: `apps/appview/src/routes/admin.ts` (add after the PUT /boards/:id handler, before `return app;`) 990 + 991 + **Step 1: Add the DELETE /api/admin/boards/:id endpoint** 992 + 993 + Also add `posts` to the existing Drizzle import at the top of the file: 994 + ```typescript 995 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts } from "@atbb/db"; 996 + ``` 997 + 998 + Then add the handler: 999 + 1000 + ```typescript 1001 + /** 1002 + * DELETE /api/admin/boards/:id 1003 + * 1004 + * Delete a board. Pre-flight: refuses with 409 if any posts have boardId 1005 + * pointing to this board. If clear, calls deleteRecord on the Forum DID's PDS. 1006 + * The firehose indexer removes the DB row asynchronously. 1007 + */ 1008 + app.delete( 1009 + "/boards/:id", 1010 + requireAuth(ctx), 1011 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 1012 + async (c) => { 1013 + const idParam = c.req.param("id"); 1014 + const id = parseBigIntParam(idParam); 1015 + if (id === null) { 1016 + return c.json({ error: "Invalid board ID" }, 400); 1017 + } 1018 + 1019 + let board: typeof boards.$inferSelect; 1020 + try { 1021 + const [row] = await ctx.db 1022 + .select() 1023 + .from(boards) 1024 + .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid))) 1025 + .limit(1); 1026 + 1027 + if (!row) { 1028 + return c.json({ error: "Board not found" }, 404); 1029 + } 1030 + board = row; 1031 + } catch (error) { 1032 + return handleRouteError(c, error, "Failed to look up board", { 1033 + operation: "DELETE /api/admin/boards/:id", 1034 + logger: ctx.logger, 1035 + id: idParam, 1036 + }); 1037 + } 1038 + 1039 + // Pre-flight: refuse if any posts reference this board 1040 + try { 1041 + const [postCount] = await ctx.db 1042 + .select({ count: count() }) 1043 + .from(posts) 1044 + .where(eq(posts.boardId, id)); 1045 + 1046 + if (postCount && postCount.count > 0) { 1047 + return c.json( 1048 + { error: "Cannot delete board with posts. Remove all posts first." }, 1049 + 409 1050 + ); 1051 + } 1052 + } catch (error) { 1053 + return handleRouteError(c, error, "Failed to check board posts", { 1054 + operation: "DELETE /api/admin/boards/:id", 1055 + logger: ctx.logger, 1056 + id: idParam, 1057 + }); 1058 + } 1059 + 1060 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/boards/:id"); 1061 + if (agentError) return agentError; 1062 + 1063 + try { 1064 + await agent.com.atproto.repo.deleteRecord({ 1065 + repo: ctx.config.forumDid, 1066 + collection: "space.atbb.forum.board", 1067 + rkey: board.rkey, 1068 + }); 1069 + 1070 + return c.json({ success: true }); 1071 + } catch (error) { 1072 + return handleRouteError(c, error, "Failed to delete board", { 1073 + operation: "DELETE /api/admin/boards/:id", 1074 + logger: ctx.logger, 1075 + id: idParam, 1076 + }); 1077 + } 1078 + } 1079 + ); 1080 + ``` 1081 + 1082 + **Step 2: Run all admin tests to verify everything passes** 1083 + 1084 + ```bash 1085 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 1086 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 1087 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -20 1088 + ``` 1089 + 1090 + Expected: All tests PASS, no failures. 1091 + 1092 + **Step 3: Run the full test suite** 1093 + 1094 + ```bash 1095 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 1096 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 1097 + pnpm --filter @atbb/appview exec vitest run 2>&1 | tail -20 1098 + ``` 1099 + 1100 + Expected: All tests PASS. 1101 + 1102 + **Step 4: Commit** 1103 + 1104 + ```bash 1105 + git add apps/appview/src/routes/admin.ts 1106 + git commit -m "feat(appview): DELETE /api/admin/boards/:id delete endpoint (ATB-45)" 1107 + ``` 1108 + 1109 + --- 1110 + 1111 + ## Task 7: Bruno collection — board management 1112 + 1113 + **Files:** 1114 + - Create: `bruno/AppView API/Admin/Create Board.bru` 1115 + - Create: `bruno/AppView API/Admin/Update Board.bru` 1116 + - Create: `bruno/AppView API/Admin/Delete Board.bru` 1117 + 1118 + **Step 1: Create Create Board.bru** 1119 + 1120 + ``` 1121 + meta { 1122 + name: Create Board 1123 + type: http 1124 + seq: 13 1125 + } 1126 + 1127 + post { 1128 + url: {{appview_url}}/api/admin/boards 1129 + } 1130 + 1131 + body:json { 1132 + { 1133 + "name": "General Chat", 1134 + "description": "Talk about anything.", 1135 + "sortOrder": 1, 1136 + "categoryUri": "{{category_uri}}" 1137 + } 1138 + } 1139 + 1140 + assert { 1141 + res.status: eq 201 1142 + res.body.uri: isDefined 1143 + res.body.cid: isDefined 1144 + } 1145 + 1146 + docs { 1147 + Create a new forum board within a category. Fetches the category's CID from DB 1148 + to build the categoryRef strongRef. Writes space.atbb.forum.board to the Forum 1149 + DID's PDS. The firehose indexer creates the DB row asynchronously. 1150 + 1151 + **Requires:** space.atbb.permission.manageCategories 1152 + 1153 + Body: 1154 + - name (required): Board display name 1155 + - categoryUri (required): AT URI of the parent category 1156 + - description (optional): Short description 1157 + - sortOrder (optional): Numeric sort position (lower = first) 1158 + 1159 + Returns (201): 1160 + { 1161 + "uri": "at://did:plc:.../space.atbb.forum.board/abc123", 1162 + "cid": "bafyrei..." 1163 + } 1164 + 1165 + Error codes: 1166 + - 400: Missing or empty name, missing categoryUri, malformed JSON 1167 + - 401: Not authenticated 1168 + - 403: Missing manageCategories permission 1169 + - 404: categoryUri references unknown category 1170 + - 500: ForumAgent not configured 1171 + - 503: PDS network error 1172 + } 1173 + ``` 1174 + 1175 + **Step 2: Create Update Board.bru** 1176 + 1177 + ``` 1178 + meta { 1179 + name: Update Board 1180 + type: http 1181 + seq: 14 1182 + } 1183 + 1184 + put { 1185 + url: {{appview_url}}/api/admin/boards/:id 1186 + } 1187 + 1188 + params:path { 1189 + id: {{board_id}} 1190 + } 1191 + 1192 + body:json { 1193 + { 1194 + "name": "General Chat (renamed)", 1195 + "description": "Updated description.", 1196 + "sortOrder": 2 1197 + } 1198 + } 1199 + 1200 + assert { 1201 + res.status: eq 200 1202 + res.body.uri: isDefined 1203 + res.body.cid: isDefined 1204 + } 1205 + 1206 + docs { 1207 + Update an existing forum board's name, description, and sortOrder. 1208 + Fetches existing rkey and categoryRef from DB, calls putRecord with updated 1209 + fields preserving the original category and createdAt. 1210 + 1211 + **Requires:** space.atbb.permission.manageCategories 1212 + 1213 + Path params: 1214 + - id: Board database ID (bigint as string) 1215 + 1216 + Body: 1217 + - name (required): New display name 1218 + - description (optional): New description (falls back to existing if omitted) 1219 + - sortOrder (optional): New sort position (falls back to existing if omitted) 1220 + 1221 + Returns (200): 1222 + { 1223 + "uri": "at://did:plc:.../space.atbb.forum.board/abc123", 1224 + "cid": "bafyrei..." 1225 + } 1226 + 1227 + Error codes: 1228 + - 400: Missing name, empty name, invalid ID format, malformed JSON 1229 + - 401: Not authenticated 1230 + - 403: Missing manageCategories permission 1231 + - 404: Board not found 1232 + - 500: ForumAgent not configured 1233 + - 503: PDS network error 1234 + } 1235 + ``` 1236 + 1237 + **Step 3: Create Delete Board.bru** 1238 + 1239 + ``` 1240 + meta { 1241 + name: Delete Board 1242 + type: http 1243 + seq: 15 1244 + } 1245 + 1246 + delete { 1247 + url: {{appview_url}}/api/admin/boards/:id 1248 + } 1249 + 1250 + params:path { 1251 + id: {{board_id}} 1252 + } 1253 + 1254 + assert { 1255 + res.status: eq 200 1256 + res.body.success: isTrue 1257 + } 1258 + 1259 + docs { 1260 + Delete a forum board. Pre-flight check refuses with 409 if any posts reference 1261 + this board. If clear, calls deleteRecord on the Forum DID's PDS. 1262 + The firehose indexer removes the DB row asynchronously. 1263 + 1264 + **Requires:** space.atbb.permission.manageCategories 1265 + 1266 + Path params: 1267 + - id: Board database ID (bigint as string) 1268 + 1269 + Returns (200): 1270 + { 1271 + "success": true 1272 + } 1273 + 1274 + Error codes: 1275 + - 400: Invalid ID format 1276 + - 401: Not authenticated 1277 + - 403: Missing manageCategories permission 1278 + - 404: Board not found 1279 + - 409: Board has posts — remove them first 1280 + - 500: ForumAgent not configured 1281 + - 503: PDS network error 1282 + } 1283 + ``` 1284 + 1285 + **Step 4: Commit** 1286 + 1287 + ```bash 1288 + git add "bruno/AppView API/Admin/Create Board.bru" "bruno/AppView API/Admin/Update Board.bru" "bruno/AppView API/Admin/Delete Board.bru" 1289 + git commit -m "docs(bruno): add board management API collection (ATB-45)" 1290 + ``` 1291 + 1292 + --- 1293 + 1294 + ## Task 8: Lint, full test run, and verification 1295 + 1296 + **Step 1: Run lint fix** 1297 + 1298 + ```bash 1299 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 1300 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 1301 + pnpm --filter @atbb/appview lint:fix 1302 + ``` 1303 + 1304 + Expected: No unfixable errors. 1305 + 1306 + **Step 2: Run full test suite** 1307 + 1308 + ```bash 1309 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 1310 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 1311 + pnpm --filter @atbb/appview exec vitest run 2>&1 | tail -20 1312 + ``` 1313 + 1314 + Expected: All tests PASS. 1315 + 1316 + **Step 3: Update Linear issue** 1317 + 1318 + Mark ATB-45 as "In Progress" in Linear, then as "Done" once the branch is ready for review. 1319 + 1320 + **Step 4: Request code review** 1321 + 1322 + Follow the commit-push-pr skill to push and open a PR: 1323 + ```bash 1324 + git log --oneline origin/main..HEAD 1325 + ``` 1326 + 1327 + Verify all commits are present, then push and open PR against `main`.