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-44: Admin Panel Category Management AppView Endpoints#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add POST /api/admin/categories, PUT /api/admin/categories/:id, and DELETE /api/admin/categories/:id to the AppView.

Architecture: All mutations follow the PDS-first write pattern — validate input, do DB pre-flight checks (reads only), write to Forum DID's PDS via ForumAgent, return the AT URI + CID from the PDS response. The firehose indexer handles the DB row update asynchronously. No direct DB writes in the mutation path.

Tech Stack: Hono, Drizzle ORM (postgres.js/libsql), @atproto/api (AtpAgent), @atproto/common-web (TID), Vitest, Bruno


Background#

All code for these endpoints lives in apps/appview/src/routes/admin.ts. Tests live in apps/appview/src/routes/__tests__/admin.test.ts. Bruno docs live in bruno/AppView API/Admin/.

Key helpers already in scope (admin.ts already imports these):#

  • safeParseJsonBody(c) — parses JSON body, returns { body, error } (400 on malformed JSON)
  • getForumAgentOrError(ctx, c, operation) — returns { agent, error } (500 if null, 503 if unauthenticated)
  • handleReadError(c, error, msg, ctx) — classifies DB read errors (503 DB, 503 network, 500 other)
  • handleWriteError(c, error, msg, ctx) — classifies PDS write errors (503 network, 500 other)
  • requireAuth(ctx) middleware — returns 401 if not authenticated, sets c.get("user")
  • requirePermission(ctx, permission) middleware — returns 403 if user lacks permission

Imports to ADD to admin.ts:#

import { TID } from "@atproto/common-web";
import { parseBigIntParam } from "./helpers.js";
// Add to the @atbb/db import: categories, boards
// count is already imported from drizzle-orm

The category lexicon (space.atbb.forum.category):#

Required: name (string, max 100 graphemes), createdAt (datetime)
Optional: description (string), slug (string), sortOrder (integer ≥ 0)

DB schema for categories (packages/db/src/schema.ts):#

categories: { id: bigserial, did, rkey, cid, name, description, slug, sortOrder, forumId, createdAt, indexedAt }
boards: { id: bigserial, did, rkey, cid, name, ..., categoryId (FK  categories.id), categoryUri }

Test mock setup pattern:#

// In beforeEach — extend the ForumAgent mock to include deleteRecord
let mockDeleteRecord: ReturnType<typeof vi.fn>;
mockDeleteRecord = vi.fn().mockResolvedValue({});

ctx.forumAgent = {
  getAgent: () => ({
    com: {
      atproto: {
        repo: {
          putRecord: mockPutRecord,
          deleteRecord: mockDeleteRecord,
        },
      },
    },
  }),
} as any;

How tests verify 401:#

Set mockUser = null before the request — the requireAuth mock returns 401 when mockUser is null.

Run command:#

PATH=/path/to/monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts

Task 1: Add failing tests for POST /api/admin/categories#

Files:

  • Modify: apps/appview/src/routes/__tests__/admin.test.ts

Step 1: Add a new describe block for the create endpoint

Add this after the final closing }); of the describe("GET /api/admin/members/me", ...) block (before the final }); that closes describe.sequential("Admin Routes", ...)):

describe("POST /api/admin/categories", () => {
  beforeEach(async () => {
    await ctx.cleanDatabase();

    await ctx.db.insert(forums).values({
      did: ctx.config.forumDid,
      rkey: "self",
      cid: "bafytest",
      name: "Test Forum",
      description: "A test forum",
      indexedAt: new Date(),
    });

    mockUser = { did: "did:plc:test-admin" };
    mockPutRecord.mockClear();
    mockPutRecord.mockResolvedValue({ data: { uri: "at://did:plc:test-forum/space.atbb.forum.category/tid123", cid: "bafycategory" } });
  });

  it("creates category with valid body → 201 and putRecord called", async () => {
    const res = await app.request("/api/admin/categories", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "General Discussion", description: "Talk about anything.", sortOrder: 1 }),
    });

    expect(res.status).toBe(201);
    const data = await res.json();
    expect(data.uri).toBeDefined();
    expect(data.cid).toBeDefined();
    expect(mockPutRecord).toHaveBeenCalledWith(
      expect.objectContaining({
        repo: ctx.config.forumDid,
        collection: "space.atbb.forum.category",
        record: expect.objectContaining({
          $type: "space.atbb.forum.category",
          name: "General Discussion",
          description: "Talk about anything.",
          sortOrder: 1,
          createdAt: expect.any(String),
        }),
      })
    );
  });

  it("creates category without optional fields → 201", async () => {
    const res = await app.request("/api/admin/categories", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Minimal" }),
    });

    expect(res.status).toBe(201);
    expect(mockPutRecord).toHaveBeenCalledWith(
      expect.objectContaining({
        record: expect.objectContaining({ name: "Minimal" }),
      })
    );
  });

  it("returns 400 when name is missing → no PDS write", async () => {
    const res = await app.request("/api/admin/categories", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ description: "No name field" }),
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain("name");
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 400 when name is empty string → no PDS write", async () => {
    const res = await app.request("/api/admin/categories", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "   " }),
    });

    expect(res.status).toBe(400);
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 400 for malformed JSON", async () => {
    const res = await app.request("/api/admin/categories", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: "{ bad json }",
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain("Invalid JSON");
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 401 when unauthenticated → no PDS write", async () => {
    mockUser = null;

    const res = await app.request("/api/admin/categories", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(401);
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 503 when PDS network error", async () => {
    mockPutRecord.mockRejectedValue(new Error("fetch failed"));

    const res = await app.request("/api/admin/categories", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(503);
    const data = await res.json();
    expect(data.error).toContain("Unable to reach external service");
  });

  it("returns 500 when ForumAgent unavailable", async () => {
    ctx.forumAgent = null;

    const res = await app.request("/api/admin/categories", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(500);
    const data = await res.json();
    expect(data.error).toContain("Forum agent not available");
  });
});

Step 2: Run tests to confirm they fail

PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30

Expected: POST /api/admin/categories tests fail with 404 (route not found).


Task 2: Implement POST /api/admin/categories#

Files:

  • Modify: apps/appview/src/routes/admin.ts

Step 1: Add missing imports at the top of admin.ts

The current import line is:

import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors } from "@atbb/db";

Change it to:

import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards } from "@atbb/db";

Also add at the top of the file (after existing imports):

import { TID } from "@atproto/common-web";
import { parseBigIntParam } from "./helpers.js";

Step 2: Add the POST /api/admin/categories handler

Add the following inside the createAdminRoutes function, before the return app; line:

/**
 * POST /api/admin/categories
 *
 * Create a new forum category. Writes space.atbb.forum.category to Forum DID's PDS.
 * The firehose indexer creates the DB row asynchronously.
 */
app.post(
  "/categories",
  requireAuth(ctx),
  requirePermission(ctx, "space.atbb.permission.manageCategories"),
  async (c) => {
    const { body, error: parseError } = await safeParseJsonBody(c);
    if (parseError) return parseError;

    const { name, description, sortOrder } = body;

    if (typeof name !== "string" || name.trim().length === 0) {
      return c.json({ error: "name is required and must be a non-empty string" }, 400);
    }

    const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/categories");
    if (agentError) return agentError;

    const rkey = TID.nextStr();
    const now = new Date().toISOString();

    try {
      const result = await agent.com.atproto.repo.putRecord({
        repo: ctx.config.forumDid,
        collection: "space.atbb.forum.category",
        rkey,
        record: {
          $type: "space.atbb.forum.category",
          name: name.trim(),
          ...(typeof description === "string" && { description: description.trim() }),
          ...(typeof sortOrder === "number" && { sortOrder }),
          createdAt: now,
        },
      });

      return c.json({ uri: result.data.uri, cid: result.data.cid }, 201);
    } catch (error) {
      return handleWriteError(c, error, "Failed to create category", {
        operation: "POST /api/admin/categories",
        logger: ctx.logger,
      });
    }
  }
);

Step 3: Run tests

PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30

Expected: All POST /api/admin/categories tests pass.

Step 4: Commit

git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts
git commit -m "feat(appview): POST /api/admin/categories create endpoint (ATB-44)"

Task 3: Add failing tests for PUT /api/admin/categories/:id#

Files:

  • Modify: apps/appview/src/routes/__tests__/admin.test.ts

Step 1: Add the describe block

Add a new describe block (after the POST block, before the final });). Note that we need categories in scope — add it to the import at line 5:

import { memberships, roles, rolePermissions, users, forums, categories } from "@atbb/db";

Then add:

describe.sequential("PUT /api/admin/categories/:id", () => {
  let categoryId: string;

  beforeEach(async () => {
    await ctx.cleanDatabase();

    await ctx.db.insert(forums).values({
      did: ctx.config.forumDid,
      rkey: "self",
      cid: "bafytest",
      name: "Test Forum",
      description: "A test forum",
      indexedAt: new Date(),
    });

    const [cat] = await ctx.db.insert(categories).values({
      did: ctx.config.forumDid,
      rkey: "tid-test-cat",
      cid: "bafycat",
      name: "Original Name",
      description: "Original description",
      sortOrder: 1,
      createdAt: new Date("2026-01-01T00:00:00.000Z"),
      indexedAt: new Date(),
    }).returning({ id: categories.id });

    categoryId = cat.id.toString();

    mockUser = { did: "did:plc:test-admin" };
    mockPutRecord.mockClear();
    mockPutRecord.mockResolvedValue({ data: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`, cid: "bafynewcid" } });
  });

  it("updates category name → 200 and putRecord called with same rkey", async () => {
    const res = await app.request(`/api/admin/categories/${categoryId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Updated Name", description: "New desc", sortOrder: 2 }),
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.uri).toBeDefined();
    expect(data.cid).toBeDefined();
    expect(mockPutRecord).toHaveBeenCalledWith(
      expect.objectContaining({
        repo: ctx.config.forumDid,
        collection: "space.atbb.forum.category",
        rkey: "tid-test-cat",  // same rkey as existing category
        record: expect.objectContaining({
          $type: "space.atbb.forum.category",
          name: "Updated Name",
          description: "New desc",
          sortOrder: 2,
        }),
      })
    );
  });

  it("preserves original createdAt on update", async () => {
    const res = await app.request(`/api/admin/categories/${categoryId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Updated Name" }),
    });

    expect(res.status).toBe(200);
    expect(mockPutRecord).toHaveBeenCalledWith(
      expect.objectContaining({
        record: expect.objectContaining({
          createdAt: "2026-01-01T00:00:00.000Z",
        }),
      })
    );
  });

  it("returns 400 when name is missing", async () => {
    const res = await app.request(`/api/admin/categories/${categoryId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ description: "No name" }),
    });

    expect(res.status).toBe(400);
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 400 for invalid category ID", async () => {
    const res = await app.request("/api/admin/categories/not-a-number", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain("Invalid category ID");
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 404 when category not found", async () => {
    const res = await app.request("/api/admin/categories/99999", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(404);
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 401 when unauthenticated", async () => {
    mockUser = null;

    const res = await app.request(`/api/admin/categories/${categoryId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(401);
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 503 when PDS network error", async () => {
    mockPutRecord.mockRejectedValue(new Error("fetch failed"));

    const res = await app.request(`/api/admin/categories/${categoryId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(503);
  });

  it("returns 500 when ForumAgent unavailable", async () => {
    ctx.forumAgent = null;

    const res = await app.request(`/api/admin/categories/${categoryId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(500);
  });
});

Step 2: Run tests to confirm they fail

PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30

Expected: PUT tests fail with 404 (route not found).


Task 4: Implement PUT /api/admin/categories/:id#

Files:

  • Modify: apps/appview/src/routes/admin.ts

Step 1: Add the PUT /api/admin/categories/:id handler

Add the following inside createAdminRoutes, after the POST /categories handler and before return app;:

/**
 * PUT /api/admin/categories/:id
 *
 * Update an existing category. Fetches existing rkey from DB, calls putRecord
 * with updated fields preserving the original createdAt.
 */
app.put(
  "/categories/:id",
  requireAuth(ctx),
  requirePermission(ctx, "space.atbb.permission.manageCategories"),
  async (c) => {
    const idParam = c.req.param("id");
    const id = parseBigIntParam(idParam);
    if (id === null) {
      return c.json({ error: "Invalid category ID" }, 400);
    }

    const { body, error: parseError } = await safeParseJsonBody(c);
    if (parseError) return parseError;

    const { name, description, sortOrder } = body;

    if (typeof name !== "string" || name.trim().length === 0) {
      return c.json({ error: "name is required and must be a non-empty string" }, 400);
    }

    let category: typeof categories.$inferSelect;
    try {
      const [row] = await ctx.db
        .select()
        .from(categories)
        .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid)))
        .limit(1);

      if (!row) {
        return c.json({ error: "Category not found" }, 404);
      }
      category = row;
    } catch (error) {
      return handleReadError(c, error, "Failed to look up category", {
        operation: "PUT /api/admin/categories/:id",
        logger: ctx.logger,
        id: idParam,
      });
    }

    const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/categories/:id");
    if (agentError) return agentError;

    try {
      const result = await agent.com.atproto.repo.putRecord({
        repo: ctx.config.forumDid,
        collection: "space.atbb.forum.category",
        rkey: category.rkey,
        record: {
          $type: "space.atbb.forum.category",
          name: name.trim(),
          ...(typeof description === "string" && { description: description.trim() }),
          ...(typeof sortOrder === "number" && { sortOrder }),
          createdAt: category.createdAt.toISOString(),
        },
      });

      return c.json({ uri: result.data.uri, cid: result.data.cid });
    } catch (error) {
      return handleWriteError(c, error, "Failed to update category", {
        operation: "PUT /api/admin/categories/:id",
        logger: ctx.logger,
        id: idParam,
      });
    }
  }
);

Step 2: Run tests

PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30

Expected: All PUT tests pass.

Step 3: Commit

git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts
git commit -m "feat(appview): PUT /api/admin/categories/:id update endpoint (ATB-44)"

Task 5: Add failing tests for DELETE /api/admin/categories/:id#

Files:

  • Modify: apps/appview/src/routes/__tests__/admin.test.ts

Step 1: Add boards to the import at line 5

import { memberships, roles, rolePermissions, users, forums, categories, boards } from "@atbb/db";

Step 2: Update the top-level beforeEach to include mockDeleteRecord

The existing outer beforeEach has:

mockPutRecord = vi.fn().mockResolvedValue({ uri: "at://...", cid: "bafytest" });

Change the ForumAgent mock to also expose deleteRecord:

mockPutRecord = vi.fn().mockResolvedValue({ data: { uri: "at://...", cid: "bafytest" } });
const mockDeleteRecord = vi.fn().mockResolvedValue({});

ctx.forumAgent = {
  getAgent: () => ({
    com: {
      atproto: {
        repo: {
          putRecord: mockPutRecord,
          deleteRecord: mockDeleteRecord,
        },
      },
    },
  }),
} as any;

Wait — mockDeleteRecord needs to be accessible inside the DELETE describe block. Promote it to module level alongside mockPutRecord:

At module level (near line 10):

let mockDeleteRecord: ReturnType<typeof vi.fn>;

Then in the outer beforeEach:

mockDeleteRecord = vi.fn().mockResolvedValue({});

And update the ForumAgent mock shape in the outer beforeEach to include it.

Step 3: Add the DELETE describe block

describe.sequential("DELETE /api/admin/categories/:id", () => {
  let categoryId: string;

  beforeEach(async () => {
    await ctx.cleanDatabase();

    await ctx.db.insert(forums).values({
      did: ctx.config.forumDid,
      rkey: "self",
      cid: "bafytest",
      name: "Test Forum",
      description: "A test forum",
      indexedAt: new Date(),
    });

    const [cat] = await ctx.db.insert(categories).values({
      did: ctx.config.forumDid,
      rkey: "tid-test-del",
      cid: "bafycat",
      name: "Delete Me",
      description: null,
      sortOrder: 1,
      createdAt: new Date(),
      indexedAt: new Date(),
    }).returning({ id: categories.id });

    categoryId = cat.id.toString();

    mockUser = { did: "did:plc:test-admin" };
    mockDeleteRecord.mockClear();
    mockDeleteRecord.mockResolvedValue({});
  });

  it("deletes empty category → 200 and deleteRecord called", async () => {
    const res = await app.request(`/api/admin/categories/${categoryId}`, {
      method: "DELETE",
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.success).toBe(true);
    expect(mockDeleteRecord).toHaveBeenCalledWith({
      repo: ctx.config.forumDid,
      collection: "space.atbb.forum.category",
      rkey: "tid-test-del",
    });
  });

  it("returns 409 when category has boards → deleteRecord NOT called", async () => {
    // Insert a category for the board to reference
    const [cat] = await ctx.db.select({ id: categories.id })
      .from(categories)
      .where(eq(categories.rkey, "tid-test-del"))
      .limit(1);

    await ctx.db.insert(boards).values({
      did: ctx.config.forumDid,
      rkey: "tid-board-1",
      cid: "bafyboard",
      name: "Blocked Board",
      categoryId: cat.id,
      categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-del`,
      createdAt: new Date(),
      indexedAt: new Date(),
    });

    const res = await app.request(`/api/admin/categories/${categoryId}`, {
      method: "DELETE",
    });

    expect(res.status).toBe(409);
    const data = await res.json();
    expect(data.error).toContain("boards");
    expect(mockDeleteRecord).not.toHaveBeenCalled();
  });

  it("returns 400 for invalid category ID", async () => {
    const res = await app.request("/api/admin/categories/not-a-number", {
      method: "DELETE",
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain("Invalid category ID");
    expect(mockDeleteRecord).not.toHaveBeenCalled();
  });

  it("returns 404 when category not found", async () => {
    const res = await app.request("/api/admin/categories/99999", {
      method: "DELETE",
    });

    expect(res.status).toBe(404);
    expect(mockDeleteRecord).not.toHaveBeenCalled();
  });

  it("returns 401 when unauthenticated", async () => {
    mockUser = null;

    const res = await app.request(`/api/admin/categories/${categoryId}`, {
      method: "DELETE",
    });

    expect(res.status).toBe(401);
    expect(mockDeleteRecord).not.toHaveBeenCalled();
  });

  it("returns 503 when PDS network error on delete", async () => {
    mockDeleteRecord.mockRejectedValue(new Error("fetch failed"));

    const res = await app.request(`/api/admin/categories/${categoryId}`, {
      method: "DELETE",
    });

    expect(res.status).toBe(503);
  });

  it("returns 500 when ForumAgent unavailable", async () => {
    ctx.forumAgent = null;

    const res = await app.request(`/api/admin/categories/${categoryId}`, {
      method: "DELETE",
    });

    expect(res.status).toBe(500);
  });
});

Step 4: Run tests to confirm they fail

PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30

Expected: DELETE tests fail with 404 (route not found).


Task 6: Implement DELETE /api/admin/categories/:id#

Files:

  • Modify: apps/appview/src/routes/admin.ts

Step 1: Add the DELETE /api/admin/categories/:id handler

Add after the PUT handler, before return app;:

/**
 * DELETE /api/admin/categories/:id
 *
 * Delete a category. Pre-flight: refuses with 409 if any boards reference this
 * category in the DB. If clear, calls deleteRecord on the Forum DID's PDS.
 * The firehose indexer removes the DB row asynchronously.
 */
app.delete(
  "/categories/:id",
  requireAuth(ctx),
  requirePermission(ctx, "space.atbb.permission.manageCategories"),
  async (c) => {
    const idParam = c.req.param("id");
    const id = parseBigIntParam(idParam);
    if (id === null) {
      return c.json({ error: "Invalid category ID" }, 400);
    }

    let category: typeof categories.$inferSelect;
    try {
      const [row] = await ctx.db
        .select()
        .from(categories)
        .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid)))
        .limit(1);

      if (!row) {
        return c.json({ error: "Category not found" }, 404);
      }
      category = row;
    } catch (error) {
      return handleReadError(c, error, "Failed to look up category", {
        operation: "DELETE /api/admin/categories/:id",
        logger: ctx.logger,
        id: idParam,
      });
    }

    // Pre-flight: refuse if any boards reference this category
    try {
      const [boardCount] = await ctx.db
        .select({ count: count() })
        .from(boards)
        .where(eq(boards.categoryId, id));

      if (boardCount && boardCount.count > 0) {
        return c.json(
          { error: "Cannot delete category with boards. Remove all boards first." },
          409
        );
      }
    } catch (error) {
      return handleReadError(c, error, "Failed to check category boards", {
        operation: "DELETE /api/admin/categories/:id",
        logger: ctx.logger,
        id: idParam,
      });
    }

    const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/categories/:id");
    if (agentError) return agentError;

    try {
      await agent.com.atproto.repo.deleteRecord({
        repo: ctx.config.forumDid,
        collection: "space.atbb.forum.category",
        rkey: category.rkey,
      });

      return c.json({ success: true });
    } catch (error) {
      return handleWriteError(c, error, "Failed to delete category", {
        operation: "DELETE /api/admin/categories/:id",
        logger: ctx.logger,
        id: idParam,
      });
    }
  }
);

Step 2: Run all tests

PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30

Expected: All tests pass.

Step 3: Run full test suite

PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview test 2>&1 | tail -20

Expected: All pass.

Step 4: Commit

git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts
git commit -m "feat(appview): DELETE /api/admin/categories/:id delete endpoint (ATB-44)"

Task 7: Add Bruno API collection files#

Files:

  • Create: bruno/AppView API/Admin/Create Category.bru
  • Create: bruno/AppView API/Admin/Update Category.bru
  • Create: bruno/AppView API/Admin/Delete Category.bru

Step 1: Create the three .bru files

bruno/AppView API/Admin/Create Category.bru:

meta {
  name: Create Category
  type: http
  seq: 10
}

post {
  url: {{appview_url}}/api/admin/categories
}

body:json {
  {
    "name": "General Discussion",
    "description": "Talk about anything.",
    "sortOrder": 1
  }
}

assert {
  res.status: eq 201
  res.body.uri: isDefined
  res.body.cid: isDefined
}

docs {
  Create a new forum category. Writes space.atbb.forum.category to the Forum DID's PDS.
  The firehose indexer creates the DB row asynchronously.

  **Requires:** space.atbb.permission.manageCategories

  Body:
  - name (required): Category display name
  - description (optional): Short description
  - sortOrder (optional): Numeric sort position (lower = first)

  Returns (201):
  {
    "uri": "at://did:plc:.../space.atbb.forum.category/abc123",
    "cid": "bafyrei..."
  }

  Error codes:
  - 400: Missing or empty name, malformed JSON
  - 401: Not authenticated
  - 403: Missing manageCategories permission
  - 500: ForumAgent not configured
  - 503: PDS network error
}

bruno/AppView API/Admin/Update Category.bru:

meta {
  name: Update Category
  type: http
  seq: 11
}

put {
  url: {{appview_url}}/api/admin/categories/:id
}

params:path {
  id: {{category_id}}
}

body:json {
  {
    "name": "Updated Name",
    "description": "Updated description.",
    "sortOrder": 2
  }
}

assert {
  res.status: eq 200
  res.body.uri: isDefined
  res.body.cid: isDefined
}

docs {
  Update an existing forum category. Fetches existing rkey from DB, calls putRecord
  with updated fields preserving the original createdAt.

  **Requires:** space.atbb.permission.manageCategories

  Path params:
  - id: Category database ID (bigint as string)

  Body:
  - name (required): New display name
  - description (optional): New description
  - sortOrder (optional): New sort position

  Returns (200):
  {
    "uri": "at://did:plc:.../space.atbb.forum.category/abc123",
    "cid": "bafyrei..."
  }

  Error codes:
  - 400: Missing name, empty name, invalid ID format, malformed JSON
  - 401: Not authenticated
  - 403: Missing manageCategories permission
  - 404: Category not found
  - 500: ForumAgent not configured
  - 503: PDS network error
}

bruno/AppView API/Admin/Delete Category.bru:

meta {
  name: Delete Category
  type: http
  seq: 12
}

delete {
  url: {{appview_url}}/api/admin/categories/:id
}

params:path {
  id: {{category_id}}
}

assert {
  res.status: eq 200
  res.body.success: isTrue
}

docs {
  Delete a forum category. Pre-flight check refuses with 409 if any boards reference
  this category. If clear, calls deleteRecord on the Forum DID's PDS.
  The firehose indexer removes the DB row asynchronously.

  **Requires:** space.atbb.permission.manageCategories

  Path params:
  - id: Category database ID (bigint as string)

  Returns (200):
  {
    "success": true
  }

  Error codes:
  - 400: Invalid ID format
  - 401: Not authenticated
  - 403: Missing manageCategories permission
  - 404: Category not found
  - 409: Category has boards — remove them first
  - 500: ForumAgent not configured
  - 503: PDS network error
}

Step 2: Commit

git add "bruno/AppView API/Admin/Create Category.bru" \
        "bruno/AppView API/Admin/Update Category.bru" \
        "bruno/AppView API/Admin/Delete Category.bru"
git commit -m "docs(bruno): add category management API collection (ATB-44)"

Final verification#

# Run full test suite
PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview test

# Lint fix
PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview lint:fix

Then update Linear ATB-44 to Done and mark items complete in docs/atproto-forum-plan.md.