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

Configure Feed

Select the types of activity you want to include in your feed.

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 (add uri to serializeCategory)
  • 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 new uri field)

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 &quot;{board.name}&quot;? 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 &quot;{category.name}&quot;? 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&apos;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/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."

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