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