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
41
# Nix build output
42
result
0
0
0
···
40
41
# Nix build output
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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 "{board.name}"? 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 "{category.name}"? 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'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`*