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: cleanup of documentation

+1896
+3
.gitignore
··· 40 40 41 41 # Nix build output 42 42 result 43 + 44 + # Playwright 45 + .playwright-mcp
docs/atproto-forum-plan.md docs/plans/complete/atproto-forum-plan.md
docs/oauth-implementation-summary.md docs/plans/complete/oauth-implementation-summary.md
+1893
docs/plans/2026-03-01-atb-47-admin-structure-ui.md
··· 1 + # ATB-47: Admin Structure UI Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add the `/admin/structure` page for full CRUD management of forum categories and boards, with pre-rendered inline edit forms, native `<dialog>` delete confirmation, and redirect-after-POST error surfacing. 6 + 7 + **Architecture:** The web server fetches category list from `GET /api/categories` (N+1 pattern: then one `GET /api/categories/:id/boards` per category in parallel, matching `home.tsx`). Six proxy routes translate HTML form POSTs to the correct AppView JSON API calls (PUT/DELETE). Error messages are passed via `?error=` query param on redirect. One AppView-side prerequisite: add `uri` to `serializeCategory` so "Add Board" forms know the category AT-URI. 8 + 9 + **Tech Stack:** Hono JSX, Vitest (mock-fetch pattern for web tests, real DB for AppView test), native HTML `<dialog>` for delete confirmation, `<details>`/`<summary>` for pre-rendered inline edit forms. CSS tokens in `apps/web/public/static/css/theme.css`. 10 + 11 + **Key files:** 12 + - Modify: `apps/appview/src/routes/helpers.ts` (add `uri` to `serializeCategory`) 13 + - Modify: `apps/appview/src/routes/__tests__/categories.test.ts` (test the new field) 14 + - Modify: `apps/web/src/routes/admin.tsx` (all new web routes + components) 15 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` (all new web tests) 16 + - Modify: `apps/web/public/static/css/theme.css` (structure page styles) 17 + - Modify: `bruno/AppView API/Categories/List Categories.bru` (document new `uri` field) 18 + 19 + --- 20 + 21 + ## Task 1: Add `uri` to `serializeCategory` (AppView prerequisite) 22 + 23 + The "Add Board" inline forms need the AT-URI of each parent category to pass as `categoryUri`. Currently `serializeCategory` omits the URI even though the DB row has `rkey`. This is a non-breaking additive change to the public API. 24 + 25 + **Files:** 26 + - Modify: `apps/appview/src/routes/helpers.ts` (~line 281) 27 + - Modify: `apps/appview/src/routes/__tests__/categories.test.ts` 28 + 29 + **Step 1: Find the existing test that checks `GET /api/categories` response shape** 30 + 31 + ```bash 32 + grep -n "serializes each category\|id.*string\|name.*string" \ 33 + apps/appview/src/routes/__tests__/categories.test.ts 34 + ``` 35 + 36 + You'll see a test called `"serializes each category with correct types"` that inserts a row and checks fields. This is the test to extend. 37 + 38 + **Step 2: Add a failing assertion for `uri`** 39 + 40 + In the existing `"serializes each category with correct types"` test, add after the existing field assertions: 41 + 42 + ```typescript 43 + // In apps/appview/src/routes/__tests__/categories.test.ts 44 + // Find the test that checks the response shape and add: 45 + expect(category.uri).toMatch(/^at:\/\/did:plc:/); 46 + expect(category.uri).toContain("/space.atbb.forum.category/"); 47 + ``` 48 + 49 + **Step 3: Run the failing test** 50 + 51 + ```bash 52 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run \ 53 + src/routes/__tests__/categories.test.ts 54 + ``` 55 + 56 + Expected: FAIL — `expect(undefined).toMatch(...)`. If the test passes, the field was already added; skip to Task 2. 57 + 58 + **Step 4: Add `uri` to `serializeCategory`** 59 + 60 + In `apps/appview/src/routes/helpers.ts`, find `serializeCategory` (~line 281) and add the `uri` field: 61 + 62 + ```typescript 63 + export function serializeCategory(cat: CategoryRow) { 64 + return { 65 + id: serializeBigInt(cat.id), 66 + did: cat.did, 67 + uri: `at://${cat.did}/space.atbb.forum.category/${cat.rkey}`, // ← ADD THIS 68 + name: cat.name, 69 + description: cat.description, 70 + slug: cat.slug, 71 + sortOrder: cat.sortOrder, 72 + forumId: serializeBigInt(cat.forumId), 73 + createdAt: serializeDate(cat.createdAt), 74 + indexedAt: serializeDate(cat.indexedAt), 75 + }; 76 + } 77 + ``` 78 + 79 + **Step 5: Run the test to verify it passes** 80 + 81 + ```bash 82 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run \ 83 + src/routes/__tests__/categories.test.ts 84 + ``` 85 + 86 + Expected: PASS. 87 + 88 + **Step 6: Update Bruno docs to document the new field** 89 + 90 + In `bruno/AppView API/Categories/List Categories.bru`, add `uri` to the response documentation in the `docs {}` block and add an assertion: 91 + 92 + ``` 93 + assert { 94 + res.status: eq 200 95 + res.body.categories: isDefined 96 + } 97 + ``` 98 + 99 + Add to the docs block a note that each category now includes `uri: "at://..."`. 100 + 101 + **Step 7: Commit** 102 + 103 + ```bash 104 + git add apps/appview/src/routes/helpers.ts \ 105 + apps/appview/src/routes/__tests__/categories.test.ts \ 106 + bruno/AppView\ API/Categories/List\ Categories.bru 107 + git commit -m "feat(appview): add uri field to serializeCategory (ATB-47)" 108 + ``` 109 + 110 + --- 111 + 112 + ## Task 2: Types and failing tests for `GET /admin/structure` 113 + 114 + Add TypeScript types to `admin.tsx` and write ALL failing tests for the structure page before implementing it. 115 + 116 + **Files:** 117 + - Modify: `apps/web/src/routes/admin.tsx` (add types only — no route yet) 118 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` (new describe block) 119 + 120 + **Step 1: Add types to `admin.tsx`** 121 + 122 + At the top of `apps/web/src/routes/admin.tsx`, alongside `MemberEntry` and `RoleEntry`, add: 123 + 124 + ```typescript 125 + interface CategoryEntry { 126 + id: string; 127 + did: string; 128 + uri: string; 129 + name: string; 130 + description: string | null; 131 + sortOrder: number | null; 132 + } 133 + 134 + interface BoardEntry { 135 + id: string; 136 + name: string; 137 + description: string | null; 138 + sortOrder: number | null; 139 + categoryUri: string; 140 + uri: string; 141 + } 142 + ``` 143 + 144 + **Step 2: Write failing tests** 145 + 146 + At the bottom of `apps/web/src/routes/__tests__/admin.test.tsx`, add a new describe block. The mock-fetch pattern here matches the existing tests — each authenticated request costs 2 mock calls (session + permissions), then data fetches follow. 147 + 148 + ```typescript 149 + describe("createAdminRoutes — GET /admin/structure", () => { 150 + beforeEach(() => { 151 + vi.stubGlobal("fetch", mockFetch); 152 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 153 + vi.resetModules(); 154 + }); 155 + 156 + afterEach(() => { 157 + vi.unstubAllGlobals(); 158 + vi.unstubAllEnvs(); 159 + mockFetch.mockReset(); 160 + }); 161 + 162 + function mockResponse(body: unknown, ok = true, status = 200) { 163 + return { 164 + ok, 165 + status, 166 + statusText: ok ? "OK" : "Error", 167 + json: () => Promise.resolve(body), 168 + }; 169 + } 170 + 171 + function setupSession(permissions: string[]) { 172 + mockFetch.mockResolvedValueOnce( 173 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 174 + ); 175 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 176 + } 177 + 178 + /** 179 + * Sets up mock responses for the structure page data fetches. 180 + * After the 2 session calls: 181 + * Call 3: GET /api/categories 182 + * Call 4+: GET /api/categories/:id/boards (one per category, parallel) 183 + */ 184 + function setupStructureFetch( 185 + cats: Array<{ id: string; name: string; uri: string; sortOrder?: number }>, 186 + boardsByCategory: Record<string, Array<{ id: string; name: string }>> = {} 187 + ) { 188 + mockFetch.mockResolvedValueOnce( 189 + mockResponse({ 190 + categories: cats.map((c) => ({ 191 + id: c.id, 192 + did: "did:plc:forum", 193 + uri: c.uri, 194 + name: c.name, 195 + description: null, 196 + slug: null, 197 + sortOrder: c.sortOrder ?? 1, 198 + forumId: "1", 199 + createdAt: "2025-01-01T00:00:00.000Z", 200 + indexedAt: "2025-01-01T00:00:00.000Z", 201 + })), 202 + }) 203 + ); 204 + for (const cat of cats) { 205 + const boards = boardsByCategory[cat.id] ?? []; 206 + mockFetch.mockResolvedValueOnce( 207 + mockResponse({ 208 + boards: boards.map((b) => ({ 209 + id: b.id, 210 + did: "did:plc:forum", 211 + uri: `at://did:plc:forum/space.atbb.forum.board/${b.id}`, 212 + name: b.name, 213 + description: null, 214 + slug: null, 215 + sortOrder: 1, 216 + categoryId: cat.id, 217 + categoryUri: cat.uri, 218 + createdAt: "2025-01-01T00:00:00.000Z", 219 + indexedAt: "2025-01-01T00:00:00.000Z", 220 + })), 221 + }) 222 + ); 223 + } 224 + } 225 + 226 + async function loadAdminRoutes() { 227 + const { createAdminRoutes } = await import("../admin.js"); 228 + return createAdminRoutes("http://localhost:3000"); 229 + } 230 + 231 + it("redirects unauthenticated users to /login", async () => { 232 + const routes = await loadAdminRoutes(); 233 + const res = await routes.request("/admin/structure"); 234 + expect(res.status).toBe(302); 235 + expect(res.headers.get("location")).toBe("/login"); 236 + }); 237 + 238 + it("returns 403 for authenticated user without manageCategories", async () => { 239 + setupSession(["space.atbb.permission.manageMembers"]); 240 + const routes = await loadAdminRoutes(); 241 + const res = await routes.request("/admin/structure", { 242 + headers: { cookie: "atbb_session=token" }, 243 + }); 244 + expect(res.status).toBe(403); 245 + }); 246 + 247 + it("renders structure page with category and board names", async () => { 248 + setupSession(["space.atbb.permission.manageCategories"]); 249 + setupStructureFetch( 250 + [{ id: "1", name: "General Discussion", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 251 + { "1": [{ id: "10", name: "General Chat" }] } 252 + ); 253 + 254 + const routes = await loadAdminRoutes(); 255 + const res = await routes.request("/admin/structure", { 256 + headers: { cookie: "atbb_session=token" }, 257 + }); 258 + 259 + expect(res.status).toBe(200); 260 + const html = await res.text(); 261 + expect(html).toContain("General Discussion"); 262 + expect(html).toContain("General Chat"); 263 + }); 264 + 265 + it("renders empty state when no categories exist", async () => { 266 + setupSession(["space.atbb.permission.manageCategories"]); 267 + setupStructureFetch([]); 268 + 269 + const routes = await loadAdminRoutes(); 270 + const res = await routes.request("/admin/structure", { 271 + headers: { cookie: "atbb_session=token" }, 272 + }); 273 + 274 + expect(res.status).toBe(200); 275 + const html = await res.text(); 276 + expect(html).toContain("No categories"); 277 + }); 278 + 279 + it("renders the add-category form", async () => { 280 + setupSession(["space.atbb.permission.manageCategories"]); 281 + setupStructureFetch([]); 282 + 283 + const routes = await loadAdminRoutes(); 284 + const res = await routes.request("/admin/structure", { 285 + headers: { cookie: "atbb_session=token" }, 286 + }); 287 + 288 + const html = await res.text(); 289 + expect(html).toContain('action="/admin/structure/categories"'); 290 + }); 291 + 292 + it("renders edit and delete actions for a category", async () => { 293 + setupSession(["space.atbb.permission.manageCategories"]); 294 + setupStructureFetch( 295 + [{ id: "5", name: "Projects", uri: "at://did:plc:forum/space.atbb.forum.category/xyz" }], 296 + ); 297 + 298 + const routes = await loadAdminRoutes(); 299 + const res = await routes.request("/admin/structure", { 300 + headers: { cookie: "atbb_session=token" }, 301 + }); 302 + 303 + const html = await res.text(); 304 + expect(html).toContain('action="/admin/structure/categories/5/edit"'); 305 + expect(html).toContain('action="/admin/structure/categories/5/delete"'); 306 + }); 307 + 308 + it("renders edit and delete actions for a board", async () => { 309 + setupSession(["space.atbb.permission.manageCategories"]); 310 + setupStructureFetch( 311 + [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 312 + { "1": [{ id: "20", name: "Showcase" }] } 313 + ); 314 + 315 + const routes = await loadAdminRoutes(); 316 + const res = await routes.request("/admin/structure", { 317 + headers: { cookie: "atbb_session=token" }, 318 + }); 319 + 320 + const html = await res.text(); 321 + expect(html).toContain("Showcase"); 322 + expect(html).toContain('action="/admin/structure/boards/20/edit"'); 323 + expect(html).toContain('action="/admin/structure/boards/20/delete"'); 324 + }); 325 + 326 + it("renders add-board form with categoryUri hidden input", async () => { 327 + setupSession(["space.atbb.permission.manageCategories"]); 328 + setupStructureFetch( 329 + [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 330 + ); 331 + 332 + const routes = await loadAdminRoutes(); 333 + const res = await routes.request("/admin/structure", { 334 + headers: { cookie: "atbb_session=token" }, 335 + }); 336 + 337 + const html = await res.text(); 338 + expect(html).toContain('name="categoryUri"'); 339 + expect(html).toContain('value="at://did:plc:forum/space.atbb.forum.category/abc"'); 340 + expect(html).toContain('action="/admin/structure/boards"'); 341 + }); 342 + 343 + it("renders error banner when ?error= query param is present", async () => { 344 + setupSession(["space.atbb.permission.manageCategories"]); 345 + setupStructureFetch([]); 346 + 347 + const routes = await loadAdminRoutes(); 348 + const res = await routes.request( 349 + `/admin/structure?error=${encodeURIComponent("Cannot delete category with boards. Remove all boards first.")}`, 350 + { headers: { cookie: "atbb_session=token" } } 351 + ); 352 + 353 + const html = await res.text(); 354 + expect(html).toContain("Cannot delete category with boards"); 355 + }); 356 + 357 + it("returns 503 on AppView network error fetching categories", async () => { 358 + setupSession(["space.atbb.permission.manageCategories"]); 359 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 360 + 361 + const routes = await loadAdminRoutes(); 362 + const res = await routes.request("/admin/structure", { 363 + headers: { cookie: "atbb_session=token" }, 364 + }); 365 + 366 + expect(res.status).toBe(503); 367 + const html = await res.text(); 368 + expect(html).toContain("error-display"); 369 + }); 370 + 371 + it("returns 500 on AppView server error fetching categories", async () => { 372 + setupSession(["space.atbb.permission.manageCategories"]); 373 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 374 + 375 + const routes = await loadAdminRoutes(); 376 + const res = await routes.request("/admin/structure", { 377 + headers: { cookie: "atbb_session=token" }, 378 + }); 379 + 380 + expect(res.status).toBe(500); 381 + const html = await res.text(); 382 + expect(html).toContain("error-display"); 383 + }); 384 + 385 + it("redirects to /login when AppView categories returns 401", async () => { 386 + setupSession(["space.atbb.permission.manageCategories"]); 387 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 388 + 389 + const routes = await loadAdminRoutes(); 390 + const res = await routes.request("/admin/structure", { 391 + headers: { cookie: "atbb_session=token" }, 392 + }); 393 + 394 + expect(res.status).toBe(302); 395 + expect(res.headers.get("location")).toBe("/login"); 396 + }); 397 + }); 398 + ``` 399 + 400 + **Step 3: Run the failing tests** 401 + 402 + ```bash 403 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \ 404 + src/routes/__tests__/admin.test.tsx 405 + ``` 406 + 407 + Expected: All `GET /admin/structure` tests FAIL with "route not found" or similar. 408 + 409 + **Step 4: Commit the types and tests (no implementation yet)** 410 + 411 + ```bash 412 + git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx 413 + git commit -m "test(web): add failing tests for GET /admin/structure (ATB-47)" 414 + ``` 415 + 416 + --- 417 + 418 + ## Task 3: Implement `GET /admin/structure` (page render) 419 + 420 + **Files:** 421 + - Modify: `apps/web/src/routes/admin.tsx` 422 + 423 + **Step 1: Add a local helper for error message extraction** 424 + 425 + Before the `createAdminRoutes` function in `admin.tsx`, add this private helper (used by 6 proxy routes later): 426 + 427 + ```typescript 428 + /** 429 + * Extracts the error message from an AppView error response. 430 + * Falls back to the provided default if JSON parsing fails. 431 + */ 432 + async function extractAppviewError(res: Response, fallback: string): Promise<string> { 433 + try { 434 + const data = (await res.json()) as { error?: string }; 435 + return data.error ?? fallback; 436 + } catch { 437 + return fallback; 438 + } 439 + } 440 + 441 + /** 442 + * Parses a sort order value from a form field string. 443 + * Returns 0 for invalid or missing values. 444 + */ 445 + function parseSortOrder(value: unknown): number { 446 + if (typeof value !== "string") return 0; 447 + const n = parseInt(value, 10); 448 + return Number.isFinite(n) && n >= 0 ? n : 0; 449 + } 450 + ``` 451 + 452 + **Step 2: Add structure page components** 453 + 454 + Inside `createAdminRoutes` (before the `app.get("/admin")` route), add these JSX components. Keep them as local functions — they're used only on this page. 455 + 456 + ```typescript 457 + // ─── Structure Page Components ────────────────────────────────────────── 458 + 459 + function StructureBoardRow({ board }: { board: BoardEntry }) { 460 + const dialogId = `confirm-delete-board-${board.id}`; 461 + return ( 462 + <div class="structure-board"> 463 + <div class="structure-board__header"> 464 + <span class="structure-board__name">{board.name}</span> 465 + <span class="structure-board__meta">sortOrder: {board.sortOrder ?? 0}</span> 466 + <div class="structure-board__actions"> 467 + <button 468 + type="button" 469 + class="btn btn-secondary btn-sm" 470 + onclick={`document.getElementById('edit-board-${board.id}').open=!document.getElementById('edit-board-${board.id}').open`} 471 + > 472 + Edit 473 + </button> 474 + <button 475 + type="button" 476 + class="btn btn-danger btn-sm" 477 + onclick={`document.getElementById('${dialogId}').showModal()`} 478 + > 479 + Delete 480 + </button> 481 + </div> 482 + </div> 483 + <details id={`edit-board-${board.id}`} class="structure-edit-form"> 484 + <summary class="sr-only">Edit {board.name}</summary> 485 + <form method="POST" action={`/admin/structure/boards/${board.id}/edit`} class="structure-edit-form__body"> 486 + <div class="form-group"> 487 + <label for={`edit-board-name-${board.id}`}>Name</label> 488 + <input id={`edit-board-name-${board.id}`} type="text" name="name" value={board.name} required /> 489 + </div> 490 + <div class="form-group"> 491 + <label for={`edit-board-desc-${board.id}`}>Description</label> 492 + <textarea id={`edit-board-desc-${board.id}`} name="description">{board.description ?? ""}</textarea> 493 + </div> 494 + <div class="form-group"> 495 + <label for={`edit-board-sort-${board.id}`}>Sort Order</label> 496 + <input id={`edit-board-sort-${board.id}`} type="number" name="sortOrder" min="0" value={String(board.sortOrder ?? 0)} /> 497 + </div> 498 + <button type="submit" class="btn btn-primary">Save Changes</button> 499 + </form> 500 + </details> 501 + <dialog id={dialogId} class="structure-confirm-dialog"> 502 + <p>Delete board &quot;{board.name}&quot;? This cannot be undone.</p> 503 + <form method="POST" action={`/admin/structure/boards/${board.id}/delete`} class="dialog-actions"> 504 + <button type="submit" class="btn btn-danger">Delete</button> 505 + <button 506 + type="button" 507 + class="btn btn-secondary" 508 + onclick={`document.getElementById('${dialogId}').close()`} 509 + > 510 + Cancel 511 + </button> 512 + </form> 513 + </dialog> 514 + </div> 515 + ); 516 + } 517 + 518 + function StructureCategorySection({ 519 + category, 520 + boards, 521 + }: { 522 + category: CategoryEntry; 523 + boards: BoardEntry[]; 524 + }) { 525 + const dialogId = `confirm-delete-category-${category.id}`; 526 + return ( 527 + <div class="structure-category"> 528 + <div class="structure-category__header"> 529 + <span class="structure-category__name">{category.name}</span> 530 + <span class="structure-category__meta">sortOrder: {category.sortOrder ?? 0}</span> 531 + <div class="structure-category__actions"> 532 + <button 533 + type="button" 534 + class="btn btn-secondary btn-sm" 535 + onclick={`document.getElementById('edit-category-${category.id}').open=!document.getElementById('edit-category-${category.id}').open`} 536 + > 537 + Edit 538 + </button> 539 + <button 540 + type="button" 541 + class="btn btn-danger btn-sm" 542 + onclick={`document.getElementById('${dialogId}').showModal()`} 543 + > 544 + Delete 545 + </button> 546 + </div> 547 + </div> 548 + 549 + {/* Inline edit form — pre-rendered, hidden until Edit clicked */} 550 + <details id={`edit-category-${category.id}`} class="structure-edit-form"> 551 + <summary class="sr-only">Edit {category.name}</summary> 552 + <form method="POST" action={`/admin/structure/categories/${category.id}/edit`} class="structure-edit-form__body"> 553 + <div class="form-group"> 554 + <label for={`edit-cat-name-${category.id}`}>Name</label> 555 + <input id={`edit-cat-name-${category.id}`} type="text" name="name" value={category.name} required /> 556 + </div> 557 + <div class="form-group"> 558 + <label for={`edit-cat-desc-${category.id}`}>Description</label> 559 + <textarea id={`edit-cat-desc-${category.id}`} name="description">{category.description ?? ""}</textarea> 560 + </div> 561 + <div class="form-group"> 562 + <label for={`edit-cat-sort-${category.id}`}>Sort Order</label> 563 + <input id={`edit-cat-sort-${category.id}`} type="number" name="sortOrder" min="0" value={String(category.sortOrder ?? 0)} /> 564 + </div> 565 + <button type="submit" class="btn btn-primary">Save Changes</button> 566 + </form> 567 + </details> 568 + 569 + {/* Delete confirmation dialog */} 570 + <dialog id={dialogId} class="structure-confirm-dialog"> 571 + <p>Delete category &quot;{category.name}&quot;? All boards must be removed first.</p> 572 + <form method="POST" action={`/admin/structure/categories/${category.id}/delete`} class="dialog-actions"> 573 + <button type="submit" class="btn btn-danger">Delete</button> 574 + <button 575 + type="button" 576 + class="btn btn-secondary" 577 + onclick={`document.getElementById('${dialogId}').close()`} 578 + > 579 + Cancel 580 + </button> 581 + </form> 582 + </dialog> 583 + 584 + {/* Boards nested beneath category */} 585 + <div class="structure-boards"> 586 + {boards.map((board) => ( 587 + <StructureBoardRow board={board} /> 588 + ))} 589 + <details class="structure-add-board"> 590 + <summary class="structure-add-board__trigger">+ Add Board</summary> 591 + <form method="POST" action="/admin/structure/boards" class="structure-edit-form__body"> 592 + <input type="hidden" name="categoryUri" value={category.uri} /> 593 + <div class="form-group"> 594 + <label for={`new-board-name-${category.id}`}>Name</label> 595 + <input id={`new-board-name-${category.id}`} type="text" name="name" required /> 596 + </div> 597 + <div class="form-group"> 598 + <label for={`new-board-desc-${category.id}`}>Description</label> 599 + <textarea id={`new-board-desc-${category.id}`} name="description"></textarea> 600 + </div> 601 + <div class="form-group"> 602 + <label for={`new-board-sort-${category.id}`}>Sort Order</label> 603 + <input id={`new-board-sort-${category.id}`} type="number" name="sortOrder" min="0" value="0" /> 604 + </div> 605 + <button type="submit" class="btn btn-primary">Add Board</button> 606 + </form> 607 + </details> 608 + </div> 609 + </div> 610 + ); 611 + } 612 + ``` 613 + 614 + **Step 3: Add the `GET /admin/structure` route** 615 + 616 + Add this route inside `createAdminRoutes`, after the members routes and before `return app`: 617 + 618 + ```typescript 619 + // ── GET /admin/structure ───────────────────────────────────────────────── 620 + 621 + app.get("/admin/structure", async (c) => { 622 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 623 + 624 + if (!auth.authenticated) { 625 + return c.redirect("/login"); 626 + } 627 + 628 + if (!canManageCategories(auth)) { 629 + return c.html( 630 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 631 + <PageHeader title="Forum Structure" /> 632 + <p>You don&apos;t have permission to manage forum structure.</p> 633 + </BaseLayout>, 634 + 403 635 + ); 636 + } 637 + 638 + const cookie = c.req.header("cookie") ?? ""; 639 + const errorMsg = c.req.query("error") ?? null; 640 + 641 + // Fetch category list 642 + let categoriesRes: Response; 643 + try { 644 + categoriesRes = await fetch(`${appviewUrl}/api/categories`, { 645 + headers: { Cookie: cookie }, 646 + }); 647 + } catch (error) { 648 + if (isProgrammingError(error)) throw error; 649 + logger.error("Network error fetching categories for structure page", { 650 + operation: "GET /admin/structure", 651 + error: error instanceof Error ? error.message : String(error), 652 + }); 653 + return c.html( 654 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 655 + <PageHeader title="Forum Structure" /> 656 + <ErrorDisplay 657 + message="Unable to load forum structure" 658 + detail="The forum is temporarily unavailable. Please try again." 659 + /> 660 + </BaseLayout>, 661 + 503 662 + ); 663 + } 664 + 665 + if (!categoriesRes.ok) { 666 + if (categoriesRes.status === 401) { 667 + return c.redirect("/login"); 668 + } 669 + logger.error("AppView returned error for categories list", { 670 + operation: "GET /admin/structure", 671 + status: categoriesRes.status, 672 + }); 673 + return c.html( 674 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 675 + <PageHeader title="Forum Structure" /> 676 + <ErrorDisplay 677 + message="Something went wrong" 678 + detail="Could not load forum structure. Please try again." 679 + /> 680 + </BaseLayout>, 681 + 500 682 + ); 683 + } 684 + 685 + const categoriesData = (await categoriesRes.json()) as { categories: CategoryEntry[] }; 686 + const catList = categoriesData.categories; 687 + 688 + // Fetch boards for each category in parallel (N+1 pattern — same as home.tsx) 689 + let boardsPerCategory: BoardEntry[][]; 690 + try { 691 + boardsPerCategory = await Promise.all( 692 + catList.map((cat) => 693 + fetch(`${appviewUrl}/api/categories/${cat.id}/boards`, { 694 + headers: { Cookie: cookie }, 695 + }) 696 + .then((r) => r.json() as Promise<{ boards: BoardEntry[] }>) 697 + .then((data) => data.boards) 698 + .catch(() => [] as BoardEntry[]) // Degrade gracefully per-category on failure 699 + ) 700 + ); 701 + } catch (error) { 702 + if (isProgrammingError(error)) throw error; 703 + boardsPerCategory = catList.map(() => []); 704 + } 705 + 706 + const structure = catList.map((cat, i) => ({ 707 + category: cat, 708 + boards: boardsPerCategory[i] ?? [], 709 + })); 710 + 711 + return c.html( 712 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 713 + <PageHeader title="Forum Structure" /> 714 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 715 + <div class="structure-page"> 716 + {structure.length === 0 ? ( 717 + <EmptyState message="No categories yet" /> 718 + ) : ( 719 + structure.map(({ category, boards }) => ( 720 + <StructureCategorySection category={category} boards={boards} /> 721 + )) 722 + )} 723 + <div class="structure-add-category card"> 724 + <h3>Add Category</h3> 725 + <form method="POST" action="/admin/structure/categories"> 726 + <div class="form-group"> 727 + <label for="new-cat-name">Name</label> 728 + <input id="new-cat-name" type="text" name="name" required /> 729 + </div> 730 + <div class="form-group"> 731 + <label for="new-cat-desc">Description</label> 732 + <textarea id="new-cat-desc" name="description"></textarea> 733 + </div> 734 + <div class="form-group"> 735 + <label for="new-cat-sort">Sort Order</label> 736 + <input id="new-cat-sort" type="number" name="sortOrder" min="0" value="0" /> 737 + </div> 738 + <button type="submit" class="btn btn-primary">Add Category</button> 739 + </form> 740 + </div> 741 + </div> 742 + </BaseLayout> 743 + ); 744 + }); 745 + ``` 746 + 747 + **Step 4: Run the structure page tests** 748 + 749 + ```bash 750 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \ 751 + src/routes/__tests__/admin.test.tsx 752 + ``` 753 + 754 + Expected: All `GET /admin/structure` tests PASS. Earlier tests still pass. 755 + 756 + **Step 5: Commit** 757 + 758 + ```bash 759 + git add apps/web/src/routes/admin.tsx 760 + git commit -m "feat(web): add GET /admin/structure page with category/board listing (ATB-47)" 761 + ``` 762 + 763 + --- 764 + 765 + ## Task 4: Failing tests for category proxy routes 766 + 767 + Write ALL failing tests for the three category proxy routes before implementing them. 768 + 769 + **Files:** 770 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` 771 + 772 + **Step 1: Add failing tests for POST /admin/structure/categories (create)** 773 + 774 + ```typescript 775 + describe("createAdminRoutes — POST /admin/structure/categories", () => { 776 + beforeEach(() => { 777 + vi.stubGlobal("fetch", mockFetch); 778 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 779 + vi.resetModules(); 780 + }); 781 + 782 + afterEach(() => { 783 + vi.unstubAllGlobals(); 784 + vi.unstubAllEnvs(); 785 + mockFetch.mockReset(); 786 + }); 787 + 788 + function mockResponse(body: unknown, ok = true, status = 200) { 789 + return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 790 + } 791 + 792 + function setupSession(permissions: string[]) { 793 + mockFetch.mockResolvedValueOnce( 794 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 795 + ); 796 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 797 + } 798 + 799 + async function loadAdminRoutes() { 800 + const { createAdminRoutes } = await import("../admin.js"); 801 + return createAdminRoutes("http://localhost:3000"); 802 + } 803 + 804 + it("redirects to /admin/structure on success", async () => { 805 + setupSession(["space.atbb.permission.manageCategories"]); 806 + mockFetch.mockResolvedValueOnce( 807 + mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.category/abc", cid: "bafyrei..." }, true, 201) 808 + ); 809 + 810 + const routes = await loadAdminRoutes(); 811 + const res = await routes.request("/admin/structure/categories", { 812 + method: "POST", 813 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 814 + body: new URLSearchParams({ name: "New Category", description: "Desc", sortOrder: "1" }).toString(), 815 + }); 816 + 817 + expect(res.status).toBe(302); 818 + expect(res.headers.get("location")).toBe("/admin/structure"); 819 + // Confirm the AppView POST was called with JSON body 820 + expect(mockFetch).toHaveBeenCalledWith( 821 + expect.stringContaining("/api/admin/categories"), 822 + expect.objectContaining({ method: "POST" }) 823 + ); 824 + }); 825 + 826 + it("redirects with ?error= and makes no AppView call when name is empty", async () => { 827 + setupSession(["space.atbb.permission.manageCategories"]); 828 + 829 + const routes = await loadAdminRoutes(); 830 + const res = await routes.request("/admin/structure/categories", { 831 + method: "POST", 832 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 833 + body: new URLSearchParams({ name: "", description: "" }).toString(), 834 + }); 835 + 836 + expect(res.status).toBe(302); 837 + const location = res.headers.get("location") ?? ""; 838 + expect(location).toMatch(/\/admin\/structure\?error=/); 839 + // Only 2 session fetch calls made, no AppView call 840 + expect(mockFetch).toHaveBeenCalledTimes(2); 841 + }); 842 + 843 + it("redirects with ?error= on AppView 409", async () => { 844 + setupSession(["space.atbb.permission.manageCategories"]); 845 + mockFetch.mockResolvedValueOnce( 846 + mockResponse({ error: "Conflict error" }, false, 409) 847 + ); 848 + 849 + const routes = await loadAdminRoutes(); 850 + const res = await routes.request("/admin/structure/categories", { 851 + method: "POST", 852 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 853 + body: new URLSearchParams({ name: "Test", description: "" }).toString(), 854 + }); 855 + 856 + expect(res.status).toBe(302); 857 + const location = res.headers.get("location") ?? ""; 858 + expect(location).toMatch(/\/admin\/structure\?error=/); 859 + }); 860 + 861 + it("redirects to /login on AppView 401", async () => { 862 + setupSession(["space.atbb.permission.manageCategories"]); 863 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 864 + 865 + const routes = await loadAdminRoutes(); 866 + const res = await routes.request("/admin/structure/categories", { 867 + method: "POST", 868 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 869 + body: new URLSearchParams({ name: "Test", description: "" }).toString(), 870 + }); 871 + 872 + expect(res.status).toBe(302); 873 + expect(res.headers.get("location")).toBe("/login"); 874 + }); 875 + 876 + it("redirects with 'temporarily unavailable' error on network failure", async () => { 877 + setupSession(["space.atbb.permission.manageCategories"]); 878 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 879 + 880 + const routes = await loadAdminRoutes(); 881 + const res = await routes.request("/admin/structure/categories", { 882 + method: "POST", 883 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 884 + body: new URLSearchParams({ name: "Test", description: "" }).toString(), 885 + }); 886 + 887 + expect(res.status).toBe(302); 888 + const location = res.headers.get("location") ?? ""; 889 + expect(decodeURIComponent(location)).toContain("unavailable"); 890 + }); 891 + 892 + it("returns 403 for authenticated user without manageCategories", async () => { 893 + setupSession(["space.atbb.permission.manageMembers"]); 894 + 895 + const routes = await loadAdminRoutes(); 896 + const res = await routes.request("/admin/structure/categories", { 897 + method: "POST", 898 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 899 + body: new URLSearchParams({ name: "Test" }).toString(), 900 + }); 901 + 902 + expect(res.status).toBe(403); 903 + }); 904 + 905 + it("redirects unauthenticated to /login", async () => { 906 + const routes = await loadAdminRoutes(); 907 + const res = await routes.request("/admin/structure/categories", { 908 + method: "POST", 909 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 910 + body: new URLSearchParams({ name: "Test" }).toString(), 911 + }); 912 + 913 + expect(res.status).toBe(302); 914 + expect(res.headers.get("location")).toBe("/login"); 915 + }); 916 + }); 917 + 918 + describe("createAdminRoutes — POST /admin/structure/categories/:id/edit", () => { 919 + // (same beforeEach/afterEach/helpers as above — copy them in) 920 + 921 + it("redirects to /admin/structure on success and calls AppView PUT", async () => { 922 + setupSession(["space.atbb.permission.manageCategories"]); 923 + mockFetch.mockResolvedValueOnce( 924 + mockResponse({ uri: "at://...", cid: "baf..." }, true, 200) 925 + ); 926 + 927 + const routes = await loadAdminRoutes(); 928 + const res = await routes.request("/admin/structure/categories/5/edit", { 929 + method: "POST", 930 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 931 + body: new URLSearchParams({ name: "Updated Name", sortOrder: "2" }).toString(), 932 + }); 933 + 934 + expect(res.status).toBe(302); 935 + expect(res.headers.get("location")).toBe("/admin/structure"); 936 + expect(mockFetch).toHaveBeenCalledWith( 937 + expect.stringContaining("/api/admin/categories/5"), 938 + expect.objectContaining({ method: "PUT" }) 939 + ); 940 + }); 941 + 942 + it("redirects with ?error= when name is empty", async () => { 943 + setupSession(["space.atbb.permission.manageCategories"]); 944 + 945 + const routes = await loadAdminRoutes(); 946 + const res = await routes.request("/admin/structure/categories/5/edit", { 947 + method: "POST", 948 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 949 + body: new URLSearchParams({ name: "", sortOrder: "1" }).toString(), 950 + }); 951 + 952 + expect(res.status).toBe(302); 953 + expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/); 954 + expect(mockFetch).toHaveBeenCalledTimes(2); // only session calls 955 + }); 956 + 957 + it("redirects with ?error= on AppView 404", async () => { 958 + setupSession(["space.atbb.permission.manageCategories"]); 959 + mockFetch.mockResolvedValueOnce(mockResponse({ error: "Category not found" }, false, 404)); 960 + 961 + const routes = await loadAdminRoutes(); 962 + const res = await routes.request("/admin/structure/categories/999/edit", { 963 + method: "POST", 964 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 965 + body: new URLSearchParams({ name: "Test" }).toString(), 966 + }); 967 + 968 + expect(res.status).toBe(302); 969 + const location = res.headers.get("location") ?? ""; 970 + expect(decodeURIComponent(location)).toContain("not found"); 971 + }); 972 + }); 973 + 974 + describe("createAdminRoutes — POST /admin/structure/categories/:id/delete", () => { 975 + // (same beforeEach/afterEach/helpers) 976 + 977 + it("redirects to /admin/structure on success and calls AppView DELETE", async () => { 978 + setupSession(["space.atbb.permission.manageCategories"]); 979 + mockFetch.mockResolvedValueOnce(mockResponse({ success: true }, true, 200)); 980 + 981 + const routes = await loadAdminRoutes(); 982 + const res = await routes.request("/admin/structure/categories/5/delete", { 983 + method: "POST", 984 + headers: { cookie: "atbb_session=token" }, 985 + }); 986 + 987 + expect(res.status).toBe(302); 988 + expect(res.headers.get("location")).toBe("/admin/structure"); 989 + expect(mockFetch).toHaveBeenCalledWith( 990 + expect.stringContaining("/api/admin/categories/5"), 991 + expect.objectContaining({ method: "DELETE" }) 992 + ); 993 + }); 994 + 995 + it("redirects with the AppView's 409 error message on referential integrity failure", async () => { 996 + setupSession(["space.atbb.permission.manageCategories"]); 997 + mockFetch.mockResolvedValueOnce( 998 + mockResponse( 999 + { error: "Cannot delete category with boards. Remove all boards first." }, 1000 + false, 1001 + 409 1002 + ) 1003 + ); 1004 + 1005 + const routes = await loadAdminRoutes(); 1006 + const res = await routes.request("/admin/structure/categories/5/delete", { 1007 + method: "POST", 1008 + headers: { cookie: "atbb_session=token" }, 1009 + }); 1010 + 1011 + expect(res.status).toBe(302); 1012 + const location = res.headers.get("location") ?? ""; 1013 + expect(decodeURIComponent(location)).toContain("boards"); 1014 + }); 1015 + 1016 + it("redirects with 'temporarily unavailable' on network failure", async () => { 1017 + setupSession(["space.atbb.permission.manageCategories"]); 1018 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1019 + 1020 + const routes = await loadAdminRoutes(); 1021 + const res = await routes.request("/admin/structure/categories/5/delete", { 1022 + method: "POST", 1023 + headers: { cookie: "atbb_session=token" }, 1024 + }); 1025 + 1026 + expect(res.status).toBe(302); 1027 + const location = res.headers.get("location") ?? ""; 1028 + expect(decodeURIComponent(location)).toContain("unavailable"); 1029 + }); 1030 + }); 1031 + ``` 1032 + 1033 + **Step 2: Run tests to confirm they fail** 1034 + 1035 + ```bash 1036 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \ 1037 + src/routes/__tests__/admin.test.tsx 1038 + ``` 1039 + 1040 + Expected: The new category proxy tests FAIL. Existing tests still pass. 1041 + 1042 + **Step 3: Commit the failing tests** 1043 + 1044 + ```bash 1045 + git add apps/web/src/routes/__tests__/admin.test.tsx 1046 + git commit -m "test(web): add failing tests for category proxy routes (ATB-47)" 1047 + ``` 1048 + 1049 + --- 1050 + 1051 + ## Task 5: Implement category proxy routes 1052 + 1053 + **Files:** 1054 + - Modify: `apps/web/src/routes/admin.tsx` 1055 + 1056 + **Step 1: Add the three category proxy routes** 1057 + 1058 + Inside `createAdminRoutes`, after `GET /admin/structure` and before `return app`: 1059 + 1060 + ```typescript 1061 + // ── POST /admin/structure/categories (create) ──────────────────────────── 1062 + 1063 + app.post("/admin/structure/categories", async (c) => { 1064 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1065 + if (!auth.authenticated) return c.redirect("/login"); 1066 + if (!canManageCategories(auth)) return c.text("Forbidden", 403); 1067 + 1068 + const cookie = c.req.header("cookie") ?? ""; 1069 + 1070 + let body: Record<string, string | File>; 1071 + try { 1072 + body = await c.req.parseBody(); 1073 + } catch (error) { 1074 + if (isProgrammingError(error)) throw error; 1075 + return c.redirect( 1076 + `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302 1077 + ); 1078 + } 1079 + 1080 + const name = typeof body.name === "string" ? body.name.trim() : ""; 1081 + const description = typeof body.description === "string" ? body.description.trim() : undefined; 1082 + const sortOrder = parseSortOrder(body.sortOrder); 1083 + 1084 + if (!name) { 1085 + return c.redirect( 1086 + `/admin/structure?error=${encodeURIComponent("Category name is required.")}`, 302 1087 + ); 1088 + } 1089 + 1090 + let appviewRes: Response; 1091 + try { 1092 + appviewRes = await fetch(`${appviewUrl}/api/admin/categories`, { 1093 + method: "POST", 1094 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1095 + body: JSON.stringify({ name, ...(description && { description }), sortOrder }), 1096 + }); 1097 + } catch (error) { 1098 + if (isProgrammingError(error)) throw error; 1099 + logger.error("Network error creating category", { 1100 + operation: "POST /admin/structure/categories", 1101 + error: error instanceof Error ? error.message : String(error), 1102 + }); 1103 + return c.redirect( 1104 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 1105 + ); 1106 + } 1107 + 1108 + if (appviewRes.ok) return c.redirect("/admin/structure", 302); 1109 + if (appviewRes.status === 401) return c.redirect("/login"); 1110 + 1111 + const errorMsg = await extractAppviewError(appviewRes, "Failed to create category. Please try again."); 1112 + return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302); 1113 + }); 1114 + 1115 + // ── POST /admin/structure/categories/:id/edit ──────────────────────────── 1116 + 1117 + app.post("/admin/structure/categories/:id/edit", async (c) => { 1118 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1119 + if (!auth.authenticated) return c.redirect("/login"); 1120 + if (!canManageCategories(auth)) return c.text("Forbidden", 403); 1121 + 1122 + const id = c.req.param("id"); 1123 + if (!/^\d+$/.test(id)) { 1124 + return c.redirect( 1125 + `/admin/structure?error=${encodeURIComponent("Invalid category ID.")}`, 302 1126 + ); 1127 + } 1128 + 1129 + const cookie = c.req.header("cookie") ?? ""; 1130 + 1131 + let body: Record<string, string | File>; 1132 + try { 1133 + body = await c.req.parseBody(); 1134 + } catch (error) { 1135 + if (isProgrammingError(error)) throw error; 1136 + return c.redirect( 1137 + `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302 1138 + ); 1139 + } 1140 + 1141 + const name = typeof body.name === "string" ? body.name.trim() : ""; 1142 + const description = typeof body.description === "string" ? body.description.trim() : undefined; 1143 + const sortOrder = parseSortOrder(body.sortOrder); 1144 + 1145 + if (!name) { 1146 + return c.redirect( 1147 + `/admin/structure?error=${encodeURIComponent("Category name is required.")}`, 302 1148 + ); 1149 + } 1150 + 1151 + let appviewRes: Response; 1152 + try { 1153 + appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${id}`, { 1154 + method: "PUT", 1155 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1156 + body: JSON.stringify({ name, ...(description !== undefined && { description }), sortOrder }), 1157 + }); 1158 + } catch (error) { 1159 + if (isProgrammingError(error)) throw error; 1160 + logger.error("Network error updating category", { 1161 + operation: "POST /admin/structure/categories/:id/edit", 1162 + id, 1163 + error: error instanceof Error ? error.message : String(error), 1164 + }); 1165 + return c.redirect( 1166 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 1167 + ); 1168 + } 1169 + 1170 + if (appviewRes.ok) return c.redirect("/admin/structure", 302); 1171 + if (appviewRes.status === 401) return c.redirect("/login"); 1172 + 1173 + const errorMsg = await extractAppviewError(appviewRes, "Failed to update category. Please try again."); 1174 + return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302); 1175 + }); 1176 + 1177 + // ── POST /admin/structure/categories/:id/delete ────────────────────────── 1178 + 1179 + app.post("/admin/structure/categories/:id/delete", async (c) => { 1180 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1181 + if (!auth.authenticated) return c.redirect("/login"); 1182 + if (!canManageCategories(auth)) return c.text("Forbidden", 403); 1183 + 1184 + const id = c.req.param("id"); 1185 + if (!/^\d+$/.test(id)) { 1186 + return c.redirect( 1187 + `/admin/structure?error=${encodeURIComponent("Invalid category ID.")}`, 302 1188 + ); 1189 + } 1190 + 1191 + const cookie = c.req.header("cookie") ?? ""; 1192 + 1193 + let appviewRes: Response; 1194 + try { 1195 + appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${id}`, { 1196 + method: "DELETE", 1197 + headers: { Cookie: cookie }, 1198 + }); 1199 + } catch (error) { 1200 + if (isProgrammingError(error)) throw error; 1201 + logger.error("Network error deleting category", { 1202 + operation: "POST /admin/structure/categories/:id/delete", 1203 + id, 1204 + error: error instanceof Error ? error.message : String(error), 1205 + }); 1206 + return c.redirect( 1207 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 1208 + ); 1209 + } 1210 + 1211 + if (appviewRes.ok) return c.redirect("/admin/structure", 302); 1212 + if (appviewRes.status === 401) return c.redirect("/login"); 1213 + 1214 + const errorMsg = await extractAppviewError(appviewRes, "Failed to delete category. Please try again."); 1215 + return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302); 1216 + }); 1217 + ``` 1218 + 1219 + **Step 2: Run the category proxy tests** 1220 + 1221 + ```bash 1222 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \ 1223 + src/routes/__tests__/admin.test.tsx 1224 + ``` 1225 + 1226 + Expected: All category proxy tests PASS. All earlier tests still pass. 1227 + 1228 + **Step 3: Commit** 1229 + 1230 + ```bash 1231 + git add apps/web/src/routes/admin.tsx 1232 + git commit -m "feat(web): add category create/edit/delete proxy routes (ATB-47)" 1233 + ``` 1234 + 1235 + --- 1236 + 1237 + ## Task 6: Failing tests for board proxy routes 1238 + 1239 + **Files:** 1240 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` 1241 + 1242 + **Step 1: Add failing tests** 1243 + 1244 + Add three more describe blocks to `admin.test.tsx`: 1245 + 1246 + ```typescript 1247 + describe("createAdminRoutes — POST /admin/structure/boards", () => { 1248 + // (same beforeEach/afterEach/helpers pattern) 1249 + 1250 + it("redirects to /admin/structure on success and calls AppView POST", async () => { 1251 + setupSession(["space.atbb.permission.manageCategories"]); 1252 + mockFetch.mockResolvedValueOnce( 1253 + mockResponse({ uri: "at://...", cid: "baf..." }, true, 201) 1254 + ); 1255 + 1256 + const routes = await loadAdminRoutes(); 1257 + const res = await routes.request("/admin/structure/boards", { 1258 + method: "POST", 1259 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 1260 + body: new URLSearchParams({ 1261 + name: "New Board", 1262 + description: "", 1263 + sortOrder: "1", 1264 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", 1265 + }).toString(), 1266 + }); 1267 + 1268 + expect(res.status).toBe(302); 1269 + expect(res.headers.get("location")).toBe("/admin/structure"); 1270 + expect(mockFetch).toHaveBeenCalledWith( 1271 + expect.stringContaining("/api/admin/boards"), 1272 + expect.objectContaining({ method: "POST" }) 1273 + ); 1274 + }); 1275 + 1276 + it("redirects with ?error= and no AppView call when name is empty", async () => { 1277 + setupSession(["space.atbb.permission.manageCategories"]); 1278 + 1279 + const routes = await loadAdminRoutes(); 1280 + const res = await routes.request("/admin/structure/boards", { 1281 + method: "POST", 1282 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 1283 + body: new URLSearchParams({ 1284 + name: "", 1285 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", 1286 + }).toString(), 1287 + }); 1288 + 1289 + expect(res.status).toBe(302); 1290 + expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/); 1291 + expect(mockFetch).toHaveBeenCalledTimes(2); 1292 + }); 1293 + 1294 + it("redirects with ?error= and no AppView call when categoryUri is missing", async () => { 1295 + setupSession(["space.atbb.permission.manageCategories"]); 1296 + 1297 + const routes = await loadAdminRoutes(); 1298 + const res = await routes.request("/admin/structure/boards", { 1299 + method: "POST", 1300 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 1301 + body: new URLSearchParams({ name: "Test", categoryUri: "" }).toString(), 1302 + }); 1303 + 1304 + expect(res.status).toBe(302); 1305 + expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/); 1306 + expect(mockFetch).toHaveBeenCalledTimes(2); 1307 + }); 1308 + 1309 + it("redirects with ?error= on AppView 409", async () => { 1310 + setupSession(["space.atbb.permission.manageCategories"]); 1311 + mockFetch.mockResolvedValueOnce( 1312 + mockResponse({ error: "Category not found" }, false, 409) 1313 + ); 1314 + 1315 + const routes = await loadAdminRoutes(); 1316 + const res = await routes.request("/admin/structure/boards", { 1317 + method: "POST", 1318 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 1319 + body: new URLSearchParams({ 1320 + name: "Test", 1321 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", 1322 + }).toString(), 1323 + }); 1324 + 1325 + expect(res.status).toBe(302); 1326 + expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/); 1327 + }); 1328 + }); 1329 + 1330 + describe("createAdminRoutes — POST /admin/structure/boards/:id/edit", () => { 1331 + // (same helpers) 1332 + 1333 + it("redirects to /admin/structure on success and calls AppView PUT", async () => { 1334 + setupSession(["space.atbb.permission.manageCategories"]); 1335 + mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "baf..." }, true, 200)); 1336 + 1337 + const routes = await loadAdminRoutes(); 1338 + const res = await routes.request("/admin/structure/boards/10/edit", { 1339 + method: "POST", 1340 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 1341 + body: new URLSearchParams({ name: "Updated Board", sortOrder: "3" }).toString(), 1342 + }); 1343 + 1344 + expect(res.status).toBe(302); 1345 + expect(res.headers.get("location")).toBe("/admin/structure"); 1346 + expect(mockFetch).toHaveBeenCalledWith( 1347 + expect.stringContaining("/api/admin/boards/10"), 1348 + expect.objectContaining({ method: "PUT" }) 1349 + ); 1350 + }); 1351 + 1352 + it("redirects with ?error= when name is empty", async () => { 1353 + setupSession(["space.atbb.permission.manageCategories"]); 1354 + 1355 + const routes = await loadAdminRoutes(); 1356 + const res = await routes.request("/admin/structure/boards/10/edit", { 1357 + method: "POST", 1358 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 1359 + body: new URLSearchParams({ name: "" }).toString(), 1360 + }); 1361 + 1362 + expect(res.status).toBe(302); 1363 + expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/); 1364 + expect(mockFetch).toHaveBeenCalledTimes(2); 1365 + }); 1366 + }); 1367 + 1368 + describe("createAdminRoutes — POST /admin/structure/boards/:id/delete", () => { 1369 + // (same helpers) 1370 + 1371 + it("redirects to /admin/structure on success and calls AppView DELETE", async () => { 1372 + setupSession(["space.atbb.permission.manageCategories"]); 1373 + mockFetch.mockResolvedValueOnce(mockResponse({ success: true }, true, 200)); 1374 + 1375 + const routes = await loadAdminRoutes(); 1376 + const res = await routes.request("/admin/structure/boards/10/delete", { 1377 + method: "POST", 1378 + headers: { cookie: "atbb_session=token" }, 1379 + }); 1380 + 1381 + expect(res.status).toBe(302); 1382 + expect(res.headers.get("location")).toBe("/admin/structure"); 1383 + expect(mockFetch).toHaveBeenCalledWith( 1384 + expect.stringContaining("/api/admin/boards/10"), 1385 + expect.objectContaining({ method: "DELETE" }) 1386 + ); 1387 + }); 1388 + 1389 + it("redirects with the AppView's 409 error message when board has posts", async () => { 1390 + setupSession(["space.atbb.permission.manageCategories"]); 1391 + mockFetch.mockResolvedValueOnce( 1392 + mockResponse( 1393 + { error: "Cannot delete board with posts. Remove all posts first." }, 1394 + false, 1395 + 409 1396 + ) 1397 + ); 1398 + 1399 + const routes = await loadAdminRoutes(); 1400 + const res = await routes.request("/admin/structure/boards/10/delete", { 1401 + method: "POST", 1402 + headers: { cookie: "atbb_session=token" }, 1403 + }); 1404 + 1405 + expect(res.status).toBe(302); 1406 + const location = res.headers.get("location") ?? ""; 1407 + expect(decodeURIComponent(location)).toContain("posts"); 1408 + }); 1409 + 1410 + it("redirects with 'temporarily unavailable' on network failure", async () => { 1411 + setupSession(["space.atbb.permission.manageCategories"]); 1412 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1413 + 1414 + const routes = await loadAdminRoutes(); 1415 + const res = await routes.request("/admin/structure/boards/10/delete", { 1416 + method: "POST", 1417 + headers: { cookie: "atbb_session=token" }, 1418 + }); 1419 + 1420 + expect(res.status).toBe(302); 1421 + const location = res.headers.get("location") ?? ""; 1422 + expect(decodeURIComponent(location)).toContain("unavailable"); 1423 + }); 1424 + }); 1425 + ``` 1426 + 1427 + **Step 2: Run to confirm failures** 1428 + 1429 + ```bash 1430 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \ 1431 + src/routes/__tests__/admin.test.tsx 1432 + ``` 1433 + 1434 + Expected: Board proxy tests FAIL. All earlier tests still pass. 1435 + 1436 + **Step 3: Commit failing tests** 1437 + 1438 + ```bash 1439 + git add apps/web/src/routes/__tests__/admin.test.tsx 1440 + git commit -m "test(web): add failing tests for board proxy routes (ATB-47)" 1441 + ``` 1442 + 1443 + --- 1444 + 1445 + ## Task 7: Implement board proxy routes 1446 + 1447 + **Files:** 1448 + - Modify: `apps/web/src/routes/admin.tsx` 1449 + 1450 + **Step 1: Add the three board proxy routes** 1451 + 1452 + Inside `createAdminRoutes`, after the category proxy routes and before `return app`: 1453 + 1454 + ```typescript 1455 + // ── POST /admin/structure/boards (create) ──────────────────────────────── 1456 + 1457 + app.post("/admin/structure/boards", async (c) => { 1458 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1459 + if (!auth.authenticated) return c.redirect("/login"); 1460 + if (!canManageCategories(auth)) return c.text("Forbidden", 403); 1461 + 1462 + const cookie = c.req.header("cookie") ?? ""; 1463 + 1464 + let body: Record<string, string | File>; 1465 + try { 1466 + body = await c.req.parseBody(); 1467 + } catch (error) { 1468 + if (isProgrammingError(error)) throw error; 1469 + return c.redirect( 1470 + `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302 1471 + ); 1472 + } 1473 + 1474 + const name = typeof body.name === "string" ? body.name.trim() : ""; 1475 + const description = typeof body.description === "string" ? body.description.trim() : undefined; 1476 + const sortOrder = parseSortOrder(body.sortOrder); 1477 + const categoryUri = typeof body.categoryUri === "string" ? body.categoryUri.trim() : ""; 1478 + 1479 + if (!name) { 1480 + return c.redirect( 1481 + `/admin/structure?error=${encodeURIComponent("Board name is required.")}`, 302 1482 + ); 1483 + } 1484 + 1485 + if (!categoryUri.startsWith("at://")) { 1486 + return c.redirect( 1487 + `/admin/structure?error=${encodeURIComponent("Invalid category reference. Please try again.")}`, 302 1488 + ); 1489 + } 1490 + 1491 + let appviewRes: Response; 1492 + try { 1493 + appviewRes = await fetch(`${appviewUrl}/api/admin/boards`, { 1494 + method: "POST", 1495 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1496 + body: JSON.stringify({ name, ...(description && { description }), sortOrder, categoryUri }), 1497 + }); 1498 + } catch (error) { 1499 + if (isProgrammingError(error)) throw error; 1500 + logger.error("Network error creating board", { 1501 + operation: "POST /admin/structure/boards", 1502 + error: error instanceof Error ? error.message : String(error), 1503 + }); 1504 + return c.redirect( 1505 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 1506 + ); 1507 + } 1508 + 1509 + if (appviewRes.ok) return c.redirect("/admin/structure", 302); 1510 + if (appviewRes.status === 401) return c.redirect("/login"); 1511 + 1512 + const errorMsg = await extractAppviewError(appviewRes, "Failed to create board. Please try again."); 1513 + return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302); 1514 + }); 1515 + 1516 + // ── POST /admin/structure/boards/:id/edit ──────────────────────────────── 1517 + 1518 + app.post("/admin/structure/boards/:id/edit", async (c) => { 1519 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1520 + if (!auth.authenticated) return c.redirect("/login"); 1521 + if (!canManageCategories(auth)) return c.text("Forbidden", 403); 1522 + 1523 + const id = c.req.param("id"); 1524 + if (!/^\d+$/.test(id)) { 1525 + return c.redirect( 1526 + `/admin/structure?error=${encodeURIComponent("Invalid board ID.")}`, 302 1527 + ); 1528 + } 1529 + 1530 + const cookie = c.req.header("cookie") ?? ""; 1531 + 1532 + let body: Record<string, string | File>; 1533 + try { 1534 + body = await c.req.parseBody(); 1535 + } catch (error) { 1536 + if (isProgrammingError(error)) throw error; 1537 + return c.redirect( 1538 + `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302 1539 + ); 1540 + } 1541 + 1542 + const name = typeof body.name === "string" ? body.name.trim() : ""; 1543 + const description = typeof body.description === "string" ? body.description.trim() : undefined; 1544 + const sortOrder = parseSortOrder(body.sortOrder); 1545 + 1546 + if (!name) { 1547 + return c.redirect( 1548 + `/admin/structure?error=${encodeURIComponent("Board name is required.")}`, 302 1549 + ); 1550 + } 1551 + 1552 + let appviewRes: Response; 1553 + try { 1554 + appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${id}`, { 1555 + method: "PUT", 1556 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1557 + body: JSON.stringify({ name, ...(description !== undefined && { description }), sortOrder }), 1558 + }); 1559 + } catch (error) { 1560 + if (isProgrammingError(error)) throw error; 1561 + logger.error("Network error updating board", { 1562 + operation: "POST /admin/structure/boards/:id/edit", 1563 + id, 1564 + error: error instanceof Error ? error.message : String(error), 1565 + }); 1566 + return c.redirect( 1567 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 1568 + ); 1569 + } 1570 + 1571 + if (appviewRes.ok) return c.redirect("/admin/structure", 302); 1572 + if (appviewRes.status === 401) return c.redirect("/login"); 1573 + 1574 + const errorMsg = await extractAppviewError(appviewRes, "Failed to update board. Please try again."); 1575 + return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302); 1576 + }); 1577 + 1578 + // ── POST /admin/structure/boards/:id/delete ────────────────────────────── 1579 + 1580 + app.post("/admin/structure/boards/:id/delete", async (c) => { 1581 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1582 + if (!auth.authenticated) return c.redirect("/login"); 1583 + if (!canManageCategories(auth)) return c.text("Forbidden", 403); 1584 + 1585 + const id = c.req.param("id"); 1586 + if (!/^\d+$/.test(id)) { 1587 + return c.redirect( 1588 + `/admin/structure?error=${encodeURIComponent("Invalid board ID.")}`, 302 1589 + ); 1590 + } 1591 + 1592 + const cookie = c.req.header("cookie") ?? ""; 1593 + 1594 + let appviewRes: Response; 1595 + try { 1596 + appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${id}`, { 1597 + method: "DELETE", 1598 + headers: { Cookie: cookie }, 1599 + }); 1600 + } catch (error) { 1601 + if (isProgrammingError(error)) throw error; 1602 + logger.error("Network error deleting board", { 1603 + operation: "POST /admin/structure/boards/:id/delete", 1604 + id, 1605 + error: error instanceof Error ? error.message : String(error), 1606 + }); 1607 + return c.redirect( 1608 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 1609 + ); 1610 + } 1611 + 1612 + if (appviewRes.ok) return c.redirect("/admin/structure", 302); 1613 + if (appviewRes.status === 401) return c.redirect("/login"); 1614 + 1615 + const errorMsg = await extractAppviewError(appviewRes, "Failed to delete board. Please try again."); 1616 + return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302); 1617 + }); 1618 + ``` 1619 + 1620 + **Step 2: Run all tests** 1621 + 1622 + ```bash 1623 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \ 1624 + src/routes/__tests__/admin.test.tsx 1625 + ``` 1626 + 1627 + Expected: ALL tests PASS. 1628 + 1629 + **Step 3: Commit** 1630 + 1631 + ```bash 1632 + git add apps/web/src/routes/admin.tsx 1633 + git commit -m "feat(web): add board create/edit/delete proxy routes (ATB-47)" 1634 + ``` 1635 + 1636 + --- 1637 + 1638 + ## Task 8: CSS for the structure page 1639 + 1640 + **Files:** 1641 + - Modify: `apps/web/public/static/css/theme.css` 1642 + 1643 + **Step 1: Append structure page styles** 1644 + 1645 + At the end of `theme.css` (after the `/* ─── Admin Member Table ─── */` section), add: 1646 + 1647 + ```css 1648 + /* ─── Admin Structure Page ───────────────────────────────────────────────── */ 1649 + 1650 + .structure-error-banner { 1651 + background-color: var(--color-danger); 1652 + color: var(--color-surface); 1653 + padding: var(--space-sm) var(--space-md); 1654 + font-weight: var(--font-weight-bold); 1655 + margin-bottom: var(--space-md); 1656 + border: var(--border-width) solid var(--color-border); 1657 + } 1658 + 1659 + .structure-page { 1660 + display: flex; 1661 + flex-direction: column; 1662 + gap: var(--space-lg); 1663 + margin-top: var(--space-lg); 1664 + } 1665 + 1666 + .structure-category { 1667 + border: var(--border-width) solid var(--color-border); 1668 + background-color: var(--color-surface); 1669 + box-shadow: var(--card-shadow); 1670 + } 1671 + 1672 + .structure-category__header { 1673 + display: flex; 1674 + align-items: center; 1675 + gap: var(--space-sm); 1676 + padding: var(--space-sm) var(--space-md); 1677 + background-color: var(--color-bg); 1678 + border-bottom: var(--border-width) solid var(--color-border); 1679 + } 1680 + 1681 + .structure-category__name { 1682 + font-weight: var(--font-weight-bold); 1683 + font-size: var(--font-size-lg); 1684 + flex: 1; 1685 + } 1686 + 1687 + .structure-category__meta { 1688 + color: var(--color-text-muted); 1689 + font-size: var(--font-size-sm); 1690 + } 1691 + 1692 + .structure-category__actions { 1693 + display: flex; 1694 + gap: var(--space-xs); 1695 + } 1696 + 1697 + .structure-boards { 1698 + padding: var(--space-sm) var(--space-md) var(--space-sm) calc(var(--space-md) + var(--space-lg)); 1699 + display: flex; 1700 + flex-direction: column; 1701 + gap: var(--space-sm); 1702 + } 1703 + 1704 + .structure-board { 1705 + border: var(--border-width) solid var(--color-border); 1706 + background-color: var(--color-bg); 1707 + } 1708 + 1709 + .structure-board__header { 1710 + display: flex; 1711 + align-items: center; 1712 + gap: var(--space-sm); 1713 + padding: var(--space-xs) var(--space-sm); 1714 + } 1715 + 1716 + .structure-board__name { 1717 + font-weight: var(--font-weight-bold); 1718 + flex: 1; 1719 + } 1720 + 1721 + .structure-board__meta { 1722 + color: var(--color-text-muted); 1723 + font-size: var(--font-size-sm); 1724 + } 1725 + 1726 + .structure-board__actions { 1727 + display: flex; 1728 + gap: var(--space-xs); 1729 + } 1730 + 1731 + .structure-edit-form { 1732 + border-top: var(--border-width) solid var(--color-border); 1733 + } 1734 + 1735 + .structure-edit-form__body { 1736 + display: flex; 1737 + flex-direction: column; 1738 + gap: var(--space-sm); 1739 + padding: var(--space-md); 1740 + } 1741 + 1742 + .structure-add-board { 1743 + border: var(--border-width) dashed var(--color-border); 1744 + margin-top: var(--space-xs); 1745 + } 1746 + 1747 + .structure-add-board__trigger { 1748 + cursor: pointer; 1749 + padding: var(--space-xs) var(--space-sm); 1750 + font-size: var(--font-size-sm); 1751 + color: var(--color-secondary); 1752 + list-style: none; 1753 + } 1754 + 1755 + .structure-add-board__trigger::-webkit-details-marker { 1756 + display: none; 1757 + } 1758 + 1759 + .structure-add-category { 1760 + margin-top: var(--space-md); 1761 + } 1762 + 1763 + .structure-add-category h3 { 1764 + margin-bottom: var(--space-md); 1765 + } 1766 + 1767 + /* ─── Structure Confirm Dialog ─────────────────────────────────────────────── */ 1768 + 1769 + .structure-confirm-dialog { 1770 + border: var(--border-width) solid var(--color-border); 1771 + border-radius: 0; 1772 + padding: var(--space-lg); 1773 + max-width: 420px; 1774 + width: 90vw; 1775 + box-shadow: var(--card-shadow); 1776 + background: var(--color-bg); 1777 + } 1778 + 1779 + .structure-confirm-dialog::backdrop { 1780 + background: rgba(0, 0, 0, 0.5); 1781 + } 1782 + 1783 + .structure-confirm-dialog p { 1784 + margin-top: 0; 1785 + margin-bottom: var(--space-md); 1786 + } 1787 + 1788 + .dialog-actions { 1789 + display: flex; 1790 + gap: var(--space-sm); 1791 + flex-wrap: wrap; 1792 + } 1793 + 1794 + /* ─── Shared Form Utilities ──────────────────────────────────────────────── */ 1795 + 1796 + .form-group { 1797 + display: flex; 1798 + flex-direction: column; 1799 + gap: var(--space-xs); 1800 + } 1801 + 1802 + .form-group label { 1803 + font-weight: var(--font-weight-bold); 1804 + font-size: var(--font-size-sm); 1805 + } 1806 + 1807 + .form-group input[type="text"], 1808 + .form-group input[type="number"], 1809 + .form-group textarea { 1810 + padding: var(--space-xs) var(--space-sm); 1811 + border: var(--input-border); 1812 + border-radius: var(--input-radius); 1813 + font-family: var(--font-body); 1814 + font-size: var(--font-size-base); 1815 + background-color: var(--color-surface); 1816 + width: 100%; 1817 + } 1818 + 1819 + .form-group textarea { 1820 + min-height: 80px; 1821 + resize: vertical; 1822 + } 1823 + ``` 1824 + 1825 + **Step 2: Run the full test suite to confirm no regressions** 1826 + 1827 + ```bash 1828 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm test 1829 + ``` 1830 + 1831 + Expected: All tests pass. 1832 + 1833 + **Step 3: Commit** 1834 + 1835 + ```bash 1836 + git add apps/web/public/static/css/theme.css 1837 + git commit -m "feat(web): add structure page CSS for admin panel (ATB-47)" 1838 + ``` 1839 + 1840 + --- 1841 + 1842 + ## Task 9: Final verification, Linear update, and PR 1843 + 1844 + **Step 1: Run the full test suite** 1845 + 1846 + ```bash 1847 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm test 1848 + ``` 1849 + 1850 + Expected: All tests pass with no failures. 1851 + 1852 + **Step 2: Fix any lint issues** 1853 + 1854 + ```bash 1855 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm turbo lint:fix 1856 + ``` 1857 + 1858 + **Step 3: Update Linear** 1859 + 1860 + - Mark ATB-47 status → **Done** 1861 + - Add a comment: "Implemented `/admin/structure` page with full CRUD for categories and boards. Key files: `apps/web/src/routes/admin.tsx` (page + 6 proxy routes), `apps/web/src/routes/__tests__/admin.test.tsx` (tests), `apps/web/public/static/css/theme.css` (structure styles). AppView prerequisite: added `uri` to `serializeCategory` in `apps/appview/src/routes/helpers.ts`." 1862 + 1863 + **Step 4: Create PR** 1864 + 1865 + ```bash 1866 + git push -u origin $(git branch --show-current) 1867 + gh pr create \ 1868 + --title "feat(web): admin structure management UI (ATB-47)" \ 1869 + --body "$(cat <<'EOF' 1870 + ## Summary 1871 + - Adds `/admin/structure` page for CRUD management of forum categories and boards 1872 + - Six web-layer proxy routes translate form POSTs to AppView PUT/DELETE calls 1873 + - Pre-rendered inline edit forms (`<details>`/`<summary>`) and native `<dialog>` delete confirmation 1874 + - Error messages surfaced via `?error=` redirect query param (including 409 referential integrity) 1875 + - AppView prerequisite: added `uri` field to `serializeCategory` response 1876 + 1877 + ## Test plan 1878 + - [ ] Run `pnpm test` — all tests pass 1879 + - [ ] Log in as a user with `manageCategories` permission, visit `/admin/structure` 1880 + - [ ] Create a category → verify it appears after redirect 1881 + - [ ] Edit a category name → verify update appears 1882 + - [ ] Attempt to delete a category that has boards → verify 409 error banner 1883 + - [ ] Create a board under a category → verify it appears nested 1884 + - [ ] Delete an empty board → verify it disappears 1885 + - [ ] Attempt to delete a board with posts → verify 409 error banner 1886 + - [ ] Visit `/admin/structure` without `manageCategories` → verify 403 1887 + EOF 1888 + )" 1889 + ``` 1890 + 1891 + --- 1892 + 1893 + *Plan saved: `docs/plans/2026-03-01-atb-47-admin-structure-ui.md`*