ATB-47: Admin Structure UI Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
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.
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.
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.
Key files:
- Modify:
apps/appview/src/routes/helpers.ts(adduritoserializeCategory) - Modify:
apps/appview/src/routes/__tests__/categories.test.ts(test the new field) - Modify:
apps/web/src/routes/admin.tsx(all new web routes + components) - Modify:
apps/web/src/routes/__tests__/admin.test.tsx(all new web tests) - Modify:
apps/web/public/static/css/theme.css(structure page styles) - Modify:
bruno/AppView API/Categories/List Categories.bru(document newurifield)
Task 1: Add uri to serializeCategory (AppView prerequisite)#
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.
Files:
- Modify:
apps/appview/src/routes/helpers.ts(~line 281) - Modify:
apps/appview/src/routes/__tests__/categories.test.ts
Step 1: Find the existing test that checks GET /api/categories response shape
grep -n "serializes each category\|id.*string\|name.*string" \
apps/appview/src/routes/__tests__/categories.test.ts
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.
Step 2: Add a failing assertion for uri
In the existing "serializes each category with correct types" test, add after the existing field assertions:
// In apps/appview/src/routes/__tests__/categories.test.ts
// Find the test that checks the response shape and add:
expect(category.uri).toMatch(/^at:\/\/did:plc:/);
expect(category.uri).toContain("/space.atbb.forum.category/");
Step 3: Run the failing test
PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run \
src/routes/__tests__/categories.test.ts
Expected: FAIL — expect(undefined).toMatch(...). If the test passes, the field was already added; skip to Task 2.
Step 4: Add uri to serializeCategory
In apps/appview/src/routes/helpers.ts, find serializeCategory (~line 281) and add the uri field:
export function serializeCategory(cat: CategoryRow) {
return {
id: serializeBigInt(cat.id),
did: cat.did,
uri: `at://${cat.did}/space.atbb.forum.category/${cat.rkey}`, // ← ADD THIS
name: cat.name,
description: cat.description,
slug: cat.slug,
sortOrder: cat.sortOrder,
forumId: serializeBigInt(cat.forumId),
createdAt: serializeDate(cat.createdAt),
indexedAt: serializeDate(cat.indexedAt),
};
}
Step 5: Run the test to verify it passes
PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run \
src/routes/__tests__/categories.test.ts
Expected: PASS.
Step 6: Update Bruno docs to document the new field
In bruno/AppView API/Categories/List Categories.bru, add uri to the response documentation in the docs {} block and add an assertion:
assert {
res.status: eq 200
res.body.categories: isDefined
}
Add to the docs block a note that each category now includes uri: "at://...".
Step 7: Commit
git add apps/appview/src/routes/helpers.ts \
apps/appview/src/routes/__tests__/categories.test.ts \
bruno/AppView\ API/Categories/List\ Categories.bru
git commit -m "feat(appview): add uri field to serializeCategory (ATB-47)"
Task 2: Types and failing tests for GET /admin/structure#
Add TypeScript types to admin.tsx and write ALL failing tests for the structure page before implementing it.
Files:
- Modify:
apps/web/src/routes/admin.tsx(add types only — no route yet) - Modify:
apps/web/src/routes/__tests__/admin.test.tsx(new describe block)
Step 1: Add types to admin.tsx
At the top of apps/web/src/routes/admin.tsx, alongside MemberEntry and RoleEntry, add:
interface CategoryEntry {
id: string;
did: string;
uri: string;
name: string;
description: string | null;
sortOrder: number | null;
}
interface BoardEntry {
id: string;
name: string;
description: string | null;
sortOrder: number | null;
categoryUri: string;
uri: string;
}
Step 2: Write failing tests
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.
describe("createAdminRoutes — GET /admin/structure", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
mockFetch.mockReset();
});
function mockResponse(body: unknown, ok = true, status = 200) {
return {
ok,
status,
statusText: ok ? "OK" : "Error",
json: () => Promise.resolve(body),
};
}
function setupSession(permissions: string[]) {
mockFetch.mockResolvedValueOnce(
mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
);
mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
}
/**
* Sets up mock responses for the structure page data fetches.
* After the 2 session calls:
* Call 3: GET /api/categories
* Call 4+: GET /api/categories/:id/boards (one per category, parallel)
*/
function setupStructureFetch(
cats: Array<{ id: string; name: string; uri: string; sortOrder?: number }>,
boardsByCategory: Record<string, Array<{ id: string; name: string }>> = {}
) {
mockFetch.mockResolvedValueOnce(
mockResponse({
categories: cats.map((c) => ({
id: c.id,
did: "did:plc:forum",
uri: c.uri,
name: c.name,
description: null,
slug: null,
sortOrder: c.sortOrder ?? 1,
forumId: "1",
createdAt: "2025-01-01T00:00:00.000Z",
indexedAt: "2025-01-01T00:00:00.000Z",
})),
})
);
for (const cat of cats) {
const boards = boardsByCategory[cat.id] ?? [];
mockFetch.mockResolvedValueOnce(
mockResponse({
boards: boards.map((b) => ({
id: b.id,
did: "did:plc:forum",
uri: `at://did:plc:forum/space.atbb.forum.board/${b.id}`,
name: b.name,
description: null,
slug: null,
sortOrder: 1,
categoryId: cat.id,
categoryUri: cat.uri,
createdAt: "2025-01-01T00:00:00.000Z",
indexedAt: "2025-01-01T00:00:00.000Z",
})),
})
);
}
}
async function loadAdminRoutes() {
const { createAdminRoutes } = await import("../admin.js");
return createAdminRoutes("http://localhost:3000");
}
it("redirects unauthenticated users to /login", async () => {
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login");
});
it("returns 403 for authenticated user without manageCategories", async () => {
setupSession(["space.atbb.permission.manageMembers"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(403);
});
it("renders structure page with category and board names", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
setupStructureFetch(
[{ id: "1", name: "General Discussion", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }],
{ "1": [{ id: "10", name: "General Chat" }] }
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("General Discussion");
expect(html).toContain("General Chat");
});
it("renders empty state when no categories exist", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
setupStructureFetch([]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("No categories");
});
it("renders the add-category form", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
setupStructureFetch([]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain('action="/admin/structure/categories"');
});
it("renders edit and delete actions for a category", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
setupStructureFetch(
[{ id: "5", name: "Projects", uri: "at://did:plc:forum/space.atbb.forum.category/xyz" }],
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain('action="/admin/structure/categories/5/edit"');
expect(html).toContain('action="/admin/structure/categories/5/delete"');
});
it("renders edit and delete actions for a board", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
setupStructureFetch(
[{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }],
{ "1": [{ id: "20", name: "Showcase" }] }
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("Showcase");
expect(html).toContain('action="/admin/structure/boards/20/edit"');
expect(html).toContain('action="/admin/structure/boards/20/delete"');
});
it("renders add-board form with categoryUri hidden input", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
setupStructureFetch(
[{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }],
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain('name="categoryUri"');
expect(html).toContain('value="at://did:plc:forum/space.atbb.forum.category/abc"');
expect(html).toContain('action="/admin/structure/boards"');
});
it("renders error banner when ?error= query param is present", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
setupStructureFetch([]);
const routes = await loadAdminRoutes();
const res = await routes.request(
`/admin/structure?error=${encodeURIComponent("Cannot delete category with boards. Remove all boards first.")}`,
{ headers: { cookie: "atbb_session=token" } }
);
const html = await res.text();
expect(html).toContain("Cannot delete category with boards");
});
it("returns 503 on AppView network error fetching categories", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(503);
const html = await res.text();
expect(html).toContain("error-display");
});
it("returns 500 on AppView server error fetching categories", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(500);
const html = await res.text();
expect(html).toContain("error-display");
});
it("redirects to /login when AppView categories returns 401", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login");
});
});
Step 3: Run the failing tests
PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \
src/routes/__tests__/admin.test.tsx
Expected: All GET /admin/structure tests FAIL with "route not found" or similar.
Step 4: Commit the types and tests (no implementation yet)
git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "test(web): add failing tests for GET /admin/structure (ATB-47)"
Task 3: Implement GET /admin/structure (page render)#
Files:
- Modify:
apps/web/src/routes/admin.tsx
Step 1: Add a local helper for error message extraction
Before the createAdminRoutes function in admin.tsx, add this private helper (used by 6 proxy routes later):
/**
* Extracts the error message from an AppView error response.
* Falls back to the provided default if JSON parsing fails.
*/
async function extractAppviewError(res: Response, fallback: string): Promise<string> {
try {
const data = (await res.json()) as { error?: string };
return data.error ?? fallback;
} catch {
return fallback;
}
}
/**
* Parses a sort order value from a form field string.
* Returns 0 for invalid or missing values.
*/
function parseSortOrder(value: unknown): number {
if (typeof value !== "string") return 0;
const n = parseInt(value, 10);
return Number.isFinite(n) && n >= 0 ? n : 0;
}
Step 2: Add structure page components
Inside createAdminRoutes (before the app.get("/admin") route), add these JSX components. Keep them as local functions — they're used only on this page.
// ─── Structure Page Components ──────────────────────────────────────────
function StructureBoardRow({ board }: { board: BoardEntry }) {
const dialogId = `confirm-delete-board-${board.id}`;
return (
<div class="structure-board">
<div class="structure-board__header">
<span class="structure-board__name">{board.name}</span>
<span class="structure-board__meta">sortOrder: {board.sortOrder ?? 0}</span>
<div class="structure-board__actions">
<button
type="button"
class="btn btn-secondary btn-sm"
onclick={`document.getElementById('edit-board-${board.id}').open=!document.getElementById('edit-board-${board.id}').open`}
>
Edit
</button>
<button
type="button"
class="btn btn-danger btn-sm"
onclick={`document.getElementById('${dialogId}').showModal()`}
>
Delete
</button>
</div>
</div>
<details id={`edit-board-${board.id}`} class="structure-edit-form">
<summary class="sr-only">Edit {board.name}</summary>
<form method="POST" action={`/admin/structure/boards/${board.id}/edit`} class="structure-edit-form__body">
<div class="form-group">
<label for={`edit-board-name-${board.id}`}>Name</label>
<input id={`edit-board-name-${board.id}`} type="text" name="name" value={board.name} required />
</div>
<div class="form-group">
<label for={`edit-board-desc-${board.id}`}>Description</label>
<textarea id={`edit-board-desc-${board.id}`} name="description">{board.description ?? ""}</textarea>
</div>
<div class="form-group">
<label for={`edit-board-sort-${board.id}`}>Sort Order</label>
<input id={`edit-board-sort-${board.id}`} type="number" name="sortOrder" min="0" value={String(board.sortOrder ?? 0)} />
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
</details>
<dialog id={dialogId} class="structure-confirm-dialog">
<p>Delete board "{board.name}"? This cannot be undone.</p>
<form method="POST" action={`/admin/structure/boards/${board.id}/delete`} class="dialog-actions">
<button type="submit" class="btn btn-danger">Delete</button>
<button
type="button"
class="btn btn-secondary"
onclick={`document.getElementById('${dialogId}').close()`}
>
Cancel
</button>
</form>
</dialog>
</div>
);
}
function StructureCategorySection({
category,
boards,
}: {
category: CategoryEntry;
boards: BoardEntry[];
}) {
const dialogId = `confirm-delete-category-${category.id}`;
return (
<div class="structure-category">
<div class="structure-category__header">
<span class="structure-category__name">{category.name}</span>
<span class="structure-category__meta">sortOrder: {category.sortOrder ?? 0}</span>
<div class="structure-category__actions">
<button
type="button"
class="btn btn-secondary btn-sm"
onclick={`document.getElementById('edit-category-${category.id}').open=!document.getElementById('edit-category-${category.id}').open`}
>
Edit
</button>
<button
type="button"
class="btn btn-danger btn-sm"
onclick={`document.getElementById('${dialogId}').showModal()`}
>
Delete
</button>
</div>
</div>
{/* Inline edit form — pre-rendered, hidden until Edit clicked */}
<details id={`edit-category-${category.id}`} class="structure-edit-form">
<summary class="sr-only">Edit {category.name}</summary>
<form method="POST" action={`/admin/structure/categories/${category.id}/edit`} class="structure-edit-form__body">
<div class="form-group">
<label for={`edit-cat-name-${category.id}`}>Name</label>
<input id={`edit-cat-name-${category.id}`} type="text" name="name" value={category.name} required />
</div>
<div class="form-group">
<label for={`edit-cat-desc-${category.id}`}>Description</label>
<textarea id={`edit-cat-desc-${category.id}`} name="description">{category.description ?? ""}</textarea>
</div>
<div class="form-group">
<label for={`edit-cat-sort-${category.id}`}>Sort Order</label>
<input id={`edit-cat-sort-${category.id}`} type="number" name="sortOrder" min="0" value={String(category.sortOrder ?? 0)} />
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
</details>
{/* Delete confirmation dialog */}
<dialog id={dialogId} class="structure-confirm-dialog">
<p>Delete category "{category.name}"? All boards must be removed first.</p>
<form method="POST" action={`/admin/structure/categories/${category.id}/delete`} class="dialog-actions">
<button type="submit" class="btn btn-danger">Delete</button>
<button
type="button"
class="btn btn-secondary"
onclick={`document.getElementById('${dialogId}').close()`}
>
Cancel
</button>
</form>
</dialog>
{/* Boards nested beneath category */}
<div class="structure-boards">
{boards.map((board) => (
<StructureBoardRow board={board} />
))}
<details class="structure-add-board">
<summary class="structure-add-board__trigger">+ Add Board</summary>
<form method="POST" action="/admin/structure/boards" class="structure-edit-form__body">
<input type="hidden" name="categoryUri" value={category.uri} />
<div class="form-group">
<label for={`new-board-name-${category.id}`}>Name</label>
<input id={`new-board-name-${category.id}`} type="text" name="name" required />
</div>
<div class="form-group">
<label for={`new-board-desc-${category.id}`}>Description</label>
<textarea id={`new-board-desc-${category.id}`} name="description"></textarea>
</div>
<div class="form-group">
<label for={`new-board-sort-${category.id}`}>Sort Order</label>
<input id={`new-board-sort-${category.id}`} type="number" name="sortOrder" min="0" value="0" />
</div>
<button type="submit" class="btn btn-primary">Add Board</button>
</form>
</details>
</div>
</div>
);
}
Step 3: Add the GET /admin/structure route
Add this route inside createAdminRoutes, after the members routes and before return app:
// ── GET /admin/structure ─────────────────────────────────────────────────
app.get("/admin/structure", async (c) => {
const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
if (!auth.authenticated) {
return c.redirect("/login");
}
if (!canManageCategories(auth)) {
return c.html(
<BaseLayout title="Access Denied — atBB Forum" auth={auth}>
<PageHeader title="Forum Structure" />
<p>You don't have permission to manage forum structure.</p>
</BaseLayout>,
403
);
}
const cookie = c.req.header("cookie") ?? "";
const errorMsg = c.req.query("error") ?? null;
// Fetch category list
let categoriesRes: Response;
try {
categoriesRes = await fetch(`${appviewUrl}/api/categories`, {
headers: { Cookie: cookie },
});
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error fetching categories for structure page", {
operation: "GET /admin/structure",
error: error instanceof Error ? error.message : String(error),
});
return c.html(
<BaseLayout title="Forum Structure — atBB Forum" auth={auth}>
<PageHeader title="Forum Structure" />
<ErrorDisplay
message="Unable to load forum structure"
detail="The forum is temporarily unavailable. Please try again."
/>
</BaseLayout>,
503
);
}
if (!categoriesRes.ok) {
if (categoriesRes.status === 401) {
return c.redirect("/login");
}
logger.error("AppView returned error for categories list", {
operation: "GET /admin/structure",
status: categoriesRes.status,
});
return c.html(
<BaseLayout title="Forum Structure — atBB Forum" auth={auth}>
<PageHeader title="Forum Structure" />
<ErrorDisplay
message="Something went wrong"
detail="Could not load forum structure. Please try again."
/>
</BaseLayout>,
500
);
}
const categoriesData = (await categoriesRes.json()) as { categories: CategoryEntry[] };
const catList = categoriesData.categories;
// Fetch boards for each category in parallel (N+1 pattern — same as home.tsx)
let boardsPerCategory: BoardEntry[][];
try {
boardsPerCategory = await Promise.all(
catList.map((cat) =>
fetch(`${appviewUrl}/api/categories/${cat.id}/boards`, {
headers: { Cookie: cookie },
})
.then((r) => r.json() as Promise<{ boards: BoardEntry[] }>)
.then((data) => data.boards)
.catch(() => [] as BoardEntry[]) // Degrade gracefully per-category on failure
)
);
} catch (error) {
if (isProgrammingError(error)) throw error;
boardsPerCategory = catList.map(() => []);
}
const structure = catList.map((cat, i) => ({
category: cat,
boards: boardsPerCategory[i] ?? [],
}));
return c.html(
<BaseLayout title="Forum Structure — atBB Forum" auth={auth}>
<PageHeader title="Forum Structure" />
{errorMsg && <div class="structure-error-banner">{errorMsg}</div>}
<div class="structure-page">
{structure.length === 0 ? (
<EmptyState message="No categories yet" />
) : (
structure.map(({ category, boards }) => (
<StructureCategorySection category={category} boards={boards} />
))
)}
<div class="structure-add-category card">
<h3>Add Category</h3>
<form method="POST" action="/admin/structure/categories">
<div class="form-group">
<label for="new-cat-name">Name</label>
<input id="new-cat-name" type="text" name="name" required />
</div>
<div class="form-group">
<label for="new-cat-desc">Description</label>
<textarea id="new-cat-desc" name="description"></textarea>
</div>
<div class="form-group">
<label for="new-cat-sort">Sort Order</label>
<input id="new-cat-sort" type="number" name="sortOrder" min="0" value="0" />
</div>
<button type="submit" class="btn btn-primary">Add Category</button>
</form>
</div>
</div>
</BaseLayout>
);
});
Step 4: Run the structure page tests
PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \
src/routes/__tests__/admin.test.tsx
Expected: All GET /admin/structure tests PASS. Earlier tests still pass.
Step 5: Commit
git add apps/web/src/routes/admin.tsx
git commit -m "feat(web): add GET /admin/structure page with category/board listing (ATB-47)"
Task 4: Failing tests for category proxy routes#
Write ALL failing tests for the three category proxy routes before implementing them.
Files:
- Modify:
apps/web/src/routes/__tests__/admin.test.tsx
Step 1: Add failing tests for POST /admin/structure/categories (create)
describe("createAdminRoutes — POST /admin/structure/categories", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
mockFetch.mockReset();
});
function mockResponse(body: unknown, ok = true, status = 200) {
return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
}
function setupSession(permissions: string[]) {
mockFetch.mockResolvedValueOnce(
mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
);
mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
}
async function loadAdminRoutes() {
const { createAdminRoutes } = await import("../admin.js");
return createAdminRoutes("http://localhost:3000");
}
it("redirects to /admin/structure on success", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.category/abc", cid: "bafyrei..." }, true, 201)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/categories", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({ name: "New Category", description: "Desc", sortOrder: "1" }).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin/structure");
// Confirm the AppView POST was called with JSON body
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/admin/categories"),
expect.objectContaining({ method: "POST" })
);
});
it("redirects with ?error= and makes no AppView call when name is empty", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/categories", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({ name: "", description: "" }).toString(),
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(location).toMatch(/\/admin\/structure\?error=/);
// Only 2 session fetch calls made, no AppView call
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("redirects with ?error= on AppView 409", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ error: "Conflict error" }, false, 409)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/categories", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({ name: "Test", description: "" }).toString(),
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(location).toMatch(/\/admin\/structure\?error=/);
});
it("redirects to /login on AppView 401", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/categories", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({ name: "Test", description: "" }).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login");
});
it("redirects with 'temporarily unavailable' error on network failure", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/categories", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({ name: "Test", description: "" }).toString(),
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(decodeURIComponent(location)).toContain("unavailable");
});
it("returns 403 for authenticated user without manageCategories", async () => {
setupSession(["space.atbb.permission.manageMembers"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/categories", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({ name: "Test" }).toString(),
});
expect(res.status).toBe(403);
});
it("redirects unauthenticated to /login", async () => {
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/categories", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ name: "Test" }).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login");
});
});
describe("createAdminRoutes — POST /admin/structure/categories/:id/edit", () => {
// (same beforeEach/afterEach/helpers as above — copy them in)
it("redirects to /admin/structure on success and calls AppView PUT", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ uri: "at://...", cid: "baf..." }, true, 200)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/categories/5/edit", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({ name: "Updated Name", sortOrder: "2" }).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin/structure");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/admin/categories/5"),
expect.objectContaining({ method: "PUT" })
);
});
it("redirects with ?error= when name is empty", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/categories/5/edit", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({ name: "", sortOrder: "1" }).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/);
expect(mockFetch).toHaveBeenCalledTimes(2); // only session calls
});
it("redirects with ?error= on AppView 404", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(mockResponse({ error: "Category not found" }, false, 404));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/categories/999/edit", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({ name: "Test" }).toString(),
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(decodeURIComponent(location)).toContain("not found");
});
});
describe("createAdminRoutes — POST /admin/structure/categories/:id/delete", () => {
// (same beforeEach/afterEach/helpers)
it("redirects to /admin/structure on success and calls AppView DELETE", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(mockResponse({ success: true }, true, 200));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/categories/5/delete", {
method: "POST",
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin/structure");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/admin/categories/5"),
expect.objectContaining({ method: "DELETE" })
);
});
it("redirects with the AppView's 409 error message on referential integrity failure", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(
mockResponse(
{ error: "Cannot delete category with boards. Remove all boards first." },
false,
409
)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/categories/5/delete", {
method: "POST",
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(decodeURIComponent(location)).toContain("boards");
});
it("redirects with 'temporarily unavailable' on network failure", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/categories/5/delete", {
method: "POST",
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(decodeURIComponent(location)).toContain("unavailable");
});
});
Step 2: Run tests to confirm they fail
PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \
src/routes/__tests__/admin.test.tsx
Expected: The new category proxy tests FAIL. Existing tests still pass.
Step 3: Commit the failing tests
git add apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "test(web): add failing tests for category proxy routes (ATB-47)"
Task 5: Implement category proxy routes#
Files:
- Modify:
apps/web/src/routes/admin.tsx
Step 1: Add the three category proxy routes
Inside createAdminRoutes, after GET /admin/structure and before return app:
// ── POST /admin/structure/categories (create) ────────────────────────────
app.post("/admin/structure/categories", async (c) => {
const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
if (!auth.authenticated) return c.redirect("/login");
if (!canManageCategories(auth)) return c.text("Forbidden", 403);
const cookie = c.req.header("cookie") ?? "";
let body: Record<string, string | File>;
try {
body = await c.req.parseBody();
} catch (error) {
if (isProgrammingError(error)) throw error;
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302
);
}
const name = typeof body.name === "string" ? body.name.trim() : "";
const description = typeof body.description === "string" ? body.description.trim() : undefined;
const sortOrder = parseSortOrder(body.sortOrder);
if (!name) {
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Category name is required.")}`, 302
);
}
let appviewRes: Response;
try {
appviewRes = await fetch(`${appviewUrl}/api/admin/categories`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({ name, ...(description && { description }), sortOrder }),
});
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error creating category", {
operation: "POST /admin/structure/categories",
error: error instanceof Error ? error.message : String(error),
});
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302
);
}
if (appviewRes.ok) return c.redirect("/admin/structure", 302);
if (appviewRes.status === 401) return c.redirect("/login");
const errorMsg = await extractAppviewError(appviewRes, "Failed to create category. Please try again.");
return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302);
});
// ── POST /admin/structure/categories/:id/edit ────────────────────────────
app.post("/admin/structure/categories/:id/edit", async (c) => {
const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
if (!auth.authenticated) return c.redirect("/login");
if (!canManageCategories(auth)) return c.text("Forbidden", 403);
const id = c.req.param("id");
if (!/^\d+$/.test(id)) {
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Invalid category ID.")}`, 302
);
}
const cookie = c.req.header("cookie") ?? "";
let body: Record<string, string | File>;
try {
body = await c.req.parseBody();
} catch (error) {
if (isProgrammingError(error)) throw error;
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302
);
}
const name = typeof body.name === "string" ? body.name.trim() : "";
const description = typeof body.description === "string" ? body.description.trim() : undefined;
const sortOrder = parseSortOrder(body.sortOrder);
if (!name) {
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Category name is required.")}`, 302
);
}
let appviewRes: Response;
try {
appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({ name, ...(description !== undefined && { description }), sortOrder }),
});
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error updating category", {
operation: "POST /admin/structure/categories/:id/edit",
id,
error: error instanceof Error ? error.message : String(error),
});
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302
);
}
if (appviewRes.ok) return c.redirect("/admin/structure", 302);
if (appviewRes.status === 401) return c.redirect("/login");
const errorMsg = await extractAppviewError(appviewRes, "Failed to update category. Please try again.");
return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302);
});
// ── POST /admin/structure/categories/:id/delete ──────────────────────────
app.post("/admin/structure/categories/:id/delete", async (c) => {
const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
if (!auth.authenticated) return c.redirect("/login");
if (!canManageCategories(auth)) return c.text("Forbidden", 403);
const id = c.req.param("id");
if (!/^\d+$/.test(id)) {
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Invalid category ID.")}`, 302
);
}
const cookie = c.req.header("cookie") ?? "";
let appviewRes: Response;
try {
appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${id}`, {
method: "DELETE",
headers: { Cookie: cookie },
});
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error deleting category", {
operation: "POST /admin/structure/categories/:id/delete",
id,
error: error instanceof Error ? error.message : String(error),
});
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302
);
}
if (appviewRes.ok) return c.redirect("/admin/structure", 302);
if (appviewRes.status === 401) return c.redirect("/login");
const errorMsg = await extractAppviewError(appviewRes, "Failed to delete category. Please try again.");
return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302);
});
Step 2: Run the category proxy tests
PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \
src/routes/__tests__/admin.test.tsx
Expected: All category proxy tests PASS. All earlier tests still pass.
Step 3: Commit
git add apps/web/src/routes/admin.tsx
git commit -m "feat(web): add category create/edit/delete proxy routes (ATB-47)"
Task 6: Failing tests for board proxy routes#
Files:
- Modify:
apps/web/src/routes/__tests__/admin.test.tsx
Step 1: Add failing tests
Add three more describe blocks to admin.test.tsx:
describe("createAdminRoutes — POST /admin/structure/boards", () => {
// (same beforeEach/afterEach/helpers pattern)
it("redirects to /admin/structure on success and calls AppView POST", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ uri: "at://...", cid: "baf..." }, true, 201)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/boards", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({
name: "New Board",
description: "",
sortOrder: "1",
categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc",
}).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin/structure");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/admin/boards"),
expect.objectContaining({ method: "POST" })
);
});
it("redirects with ?error= and no AppView call when name is empty", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/boards", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({
name: "",
categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc",
}).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("redirects with ?error= and no AppView call when categoryUri is missing", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/boards", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({ name: "Test", categoryUri: "" }).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("redirects with ?error= on AppView 409", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ error: "Category not found" }, false, 409)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/boards", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({
name: "Test",
categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc",
}).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/);
});
});
describe("createAdminRoutes — POST /admin/structure/boards/:id/edit", () => {
// (same helpers)
it("redirects to /admin/structure on success and calls AppView PUT", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "baf..." }, true, 200));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/boards/10/edit", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({ name: "Updated Board", sortOrder: "3" }).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin/structure");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/admin/boards/10"),
expect.objectContaining({ method: "PUT" })
);
});
it("redirects with ?error= when name is empty", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/boards/10/edit", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" },
body: new URLSearchParams({ name: "" }).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
describe("createAdminRoutes — POST /admin/structure/boards/:id/delete", () => {
// (same helpers)
it("redirects to /admin/structure on success and calls AppView DELETE", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(mockResponse({ success: true }, true, 200));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/boards/10/delete", {
method: "POST",
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin/structure");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/admin/boards/10"),
expect.objectContaining({ method: "DELETE" })
);
});
it("redirects with the AppView's 409 error message when board has posts", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockResolvedValueOnce(
mockResponse(
{ error: "Cannot delete board with posts. Remove all posts first." },
false,
409
)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/boards/10/delete", {
method: "POST",
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(decodeURIComponent(location)).toContain("posts");
});
it("redirects with 'temporarily unavailable' on network failure", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/structure/boards/10/delete", {
method: "POST",
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(decodeURIComponent(location)).toContain("unavailable");
});
});
Step 2: Run to confirm failures
PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \
src/routes/__tests__/admin.test.tsx
Expected: Board proxy tests FAIL. All earlier tests still pass.
Step 3: Commit failing tests
git add apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "test(web): add failing tests for board proxy routes (ATB-47)"
Task 7: Implement board proxy routes#
Files:
- Modify:
apps/web/src/routes/admin.tsx
Step 1: Add the three board proxy routes
Inside createAdminRoutes, after the category proxy routes and before return app:
// ── POST /admin/structure/boards (create) ────────────────────────────────
app.post("/admin/structure/boards", async (c) => {
const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
if (!auth.authenticated) return c.redirect("/login");
if (!canManageCategories(auth)) return c.text("Forbidden", 403);
const cookie = c.req.header("cookie") ?? "";
let body: Record<string, string | File>;
try {
body = await c.req.parseBody();
} catch (error) {
if (isProgrammingError(error)) throw error;
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302
);
}
const name = typeof body.name === "string" ? body.name.trim() : "";
const description = typeof body.description === "string" ? body.description.trim() : undefined;
const sortOrder = parseSortOrder(body.sortOrder);
const categoryUri = typeof body.categoryUri === "string" ? body.categoryUri.trim() : "";
if (!name) {
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Board name is required.")}`, 302
);
}
if (!categoryUri.startsWith("at://")) {
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Invalid category reference. Please try again.")}`, 302
);
}
let appviewRes: Response;
try {
appviewRes = await fetch(`${appviewUrl}/api/admin/boards`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({ name, ...(description && { description }), sortOrder, categoryUri }),
});
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error creating board", {
operation: "POST /admin/structure/boards",
error: error instanceof Error ? error.message : String(error),
});
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302
);
}
if (appviewRes.ok) return c.redirect("/admin/structure", 302);
if (appviewRes.status === 401) return c.redirect("/login");
const errorMsg = await extractAppviewError(appviewRes, "Failed to create board. Please try again.");
return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302);
});
// ── POST /admin/structure/boards/:id/edit ────────────────────────────────
app.post("/admin/structure/boards/:id/edit", async (c) => {
const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
if (!auth.authenticated) return c.redirect("/login");
if (!canManageCategories(auth)) return c.text("Forbidden", 403);
const id = c.req.param("id");
if (!/^\d+$/.test(id)) {
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Invalid board ID.")}`, 302
);
}
const cookie = c.req.header("cookie") ?? "";
let body: Record<string, string | File>;
try {
body = await c.req.parseBody();
} catch (error) {
if (isProgrammingError(error)) throw error;
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302
);
}
const name = typeof body.name === "string" ? body.name.trim() : "";
const description = typeof body.description === "string" ? body.description.trim() : undefined;
const sortOrder = parseSortOrder(body.sortOrder);
if (!name) {
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Board name is required.")}`, 302
);
}
let appviewRes: Response;
try {
appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({ name, ...(description !== undefined && { description }), sortOrder }),
});
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error updating board", {
operation: "POST /admin/structure/boards/:id/edit",
id,
error: error instanceof Error ? error.message : String(error),
});
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302
);
}
if (appviewRes.ok) return c.redirect("/admin/structure", 302);
if (appviewRes.status === 401) return c.redirect("/login");
const errorMsg = await extractAppviewError(appviewRes, "Failed to update board. Please try again.");
return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302);
});
// ── POST /admin/structure/boards/:id/delete ──────────────────────────────
app.post("/admin/structure/boards/:id/delete", async (c) => {
const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
if (!auth.authenticated) return c.redirect("/login");
if (!canManageCategories(auth)) return c.text("Forbidden", 403);
const id = c.req.param("id");
if (!/^\d+$/.test(id)) {
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Invalid board ID.")}`, 302
);
}
const cookie = c.req.header("cookie") ?? "";
let appviewRes: Response;
try {
appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${id}`, {
method: "DELETE",
headers: { Cookie: cookie },
});
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error deleting board", {
operation: "POST /admin/structure/boards/:id/delete",
id,
error: error instanceof Error ? error.message : String(error),
});
return c.redirect(
`/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302
);
}
if (appviewRes.ok) return c.redirect("/admin/structure", 302);
if (appviewRes.status === 401) return c.redirect("/login");
const errorMsg = await extractAppviewError(appviewRes, "Failed to delete board. Please try again.");
return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302);
});
Step 2: Run all tests
PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \
src/routes/__tests__/admin.test.tsx
Expected: ALL tests PASS.
Step 3: Commit
git add apps/web/src/routes/admin.tsx
git commit -m "feat(web): add board create/edit/delete proxy routes (ATB-47)"
Task 8: CSS for the structure page#
Files:
- Modify:
apps/web/public/static/css/theme.css
Step 1: Append structure page styles
At the end of theme.css (after the /* ─── Admin Member Table ─── */ section), add:
/* ─── Admin Structure Page ───────────────────────────────────────────────── */
.structure-error-banner {
background-color: var(--color-danger);
color: var(--color-surface);
padding: var(--space-sm) var(--space-md);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-md);
border: var(--border-width) solid var(--color-border);
}
.structure-page {
display: flex;
flex-direction: column;
gap: var(--space-lg);
margin-top: var(--space-lg);
}
.structure-category {
border: var(--border-width) solid var(--color-border);
background-color: var(--color-surface);
box-shadow: var(--card-shadow);
}
.structure-category__header {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background-color: var(--color-bg);
border-bottom: var(--border-width) solid var(--color-border);
}
.structure-category__name {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-lg);
flex: 1;
}
.structure-category__meta {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.structure-category__actions {
display: flex;
gap: var(--space-xs);
}
.structure-boards {
padding: var(--space-sm) var(--space-md) var(--space-sm) calc(var(--space-md) + var(--space-lg));
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.structure-board {
border: var(--border-width) solid var(--color-border);
background-color: var(--color-bg);
}
.structure-board__header {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
}
.structure-board__name {
font-weight: var(--font-weight-bold);
flex: 1;
}
.structure-board__meta {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.structure-board__actions {
display: flex;
gap: var(--space-xs);
}
.structure-edit-form {
border-top: var(--border-width) solid var(--color-border);
}
.structure-edit-form__body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md);
}
.structure-add-board {
border: var(--border-width) dashed var(--color-border);
margin-top: var(--space-xs);
}
.structure-add-board__trigger {
cursor: pointer;
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-secondary);
list-style: none;
}
.structure-add-board__trigger::-webkit-details-marker {
display: none;
}
.structure-add-category {
margin-top: var(--space-md);
}
.structure-add-category h3 {
margin-bottom: var(--space-md);
}
/* ─── Structure Confirm Dialog ─────────────────────────────────────────────── */
.structure-confirm-dialog {
border: var(--border-width) solid var(--color-border);
border-radius: 0;
padding: var(--space-lg);
max-width: 420px;
width: 90vw;
box-shadow: var(--card-shadow);
background: var(--color-bg);
}
.structure-confirm-dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.structure-confirm-dialog p {
margin-top: 0;
margin-bottom: var(--space-md);
}
.dialog-actions {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
/* ─── Shared Form Utilities ──────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.form-group label {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-sm);
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group textarea {
padding: var(--space-xs) var(--space-sm);
border: var(--input-border);
border-radius: var(--input-radius);
font-family: var(--font-body);
font-size: var(--font-size-base);
background-color: var(--color-surface);
width: 100%;
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
Step 2: Run the full test suite to confirm no regressions
PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm test
Expected: All tests pass.
Step 3: Commit
git add apps/web/public/static/css/theme.css
git commit -m "feat(web): add structure page CSS for admin panel (ATB-47)"
Task 9: Final verification, Linear update, and PR#
Step 1: Run the full test suite
PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm test
Expected: All tests pass with no failures.
Step 2: Fix any lint issues
PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm turbo lint:fix
Step 3: Update Linear
- Mark ATB-47 status → Done
- Add a comment: "Implemented
/admin/structurepage 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: addeduritoserializeCategoryinapps/appview/src/routes/helpers.ts."
Step 4: Create PR
git push -u origin $(git branch --show-current)
gh pr create \
--title "feat(web): admin structure management UI (ATB-47)" \
--body "$(cat <<'EOF'
## Summary
- Adds `/admin/structure` page for CRUD management of forum categories and boards
- Six web-layer proxy routes translate form POSTs to AppView PUT/DELETE calls
- Pre-rendered inline edit forms (`<details>`/`<summary>`) and native `<dialog>` delete confirmation
- Error messages surfaced via `?error=` redirect query param (including 409 referential integrity)
- AppView prerequisite: added `uri` field to `serializeCategory` response
## Test plan
- [ ] Run `pnpm test` — all tests pass
- [ ] Log in as a user with `manageCategories` permission, visit `/admin/structure`
- [ ] Create a category → verify it appears after redirect
- [ ] Edit a category name → verify update appears
- [ ] Attempt to delete a category that has boards → verify 409 error banner
- [ ] Create a board under a category → verify it appears nested
- [ ] Delete an empty board → verify it disappears
- [ ] Attempt to delete a board with posts → verify 409 error banner
- [ ] Visit `/admin/structure` without `manageCategories` → verify 403
EOF
)"
Plan saved: docs/plans/2026-03-01-atb-47-admin-structure-ui.md