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, setsc.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.