Board Management Endpoints Implementation Plan (ATB-45)#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add POST /api/admin/boards, PUT /api/admin/boards/:id, and DELETE /api/admin/boards/:id endpoints to the AppView, following the same PDS-first write pattern as category management.
Architecture: All mutations write to the Forum DID's PDS via ForumAgent.putRecord()/deleteRecord(); the firehose indexer handles DB updates asynchronously. The only synchronous DB read is looking up the category CID on create (needed to build the categoryRef strongRef required by the lexicon). Delete pre-flights against posts.boardId to refuse with 409 if posts exist.
Tech Stack: Hono, Drizzle ORM (postgres.js), @atproto/common-web (TID generation), Vitest, Bruno.
Key files to understand before starting#
- Implementation:
apps/appview/src/routes/admin.ts— add the 3 new endpoints at the bottom (beforereturn app) - Tests:
apps/appview/src/routes/__tests__/admin.test.ts— add a newdescribe.sequential("POST /api/admin/boards"...)block after the categories tests (around line 1450) - DB schema:
packages/db/src/schema.ts—boardstable hasid,did,rkey,cid,name,description,slug,sortOrder,categoryId,categoryUri,createdAt,indexedAt - Lexicon:
packages/lexicon/lexicons/space/atbb/forum/board.yaml— record hasname,description,slug,sortOrder,category(acategoryRefobject:{ category: { uri, cid } }) - Route helpers:
apps/appview/src/lib/route-errors.ts—handleRouteError,safeParseJsonBody,getForumAgentOrError - Bruno templates:
bruno/AppView API/Admin/Create Category.bru,Update Category.bru,Delete Category.bru— copy as starting point
Test setup pattern (copy from categories tests)#
The test file already has mocks at the top for requireAuth, requirePermission, mockPutRecord, and mockDeleteRecord. The board tests reuse these same mocks. No new setup needed — just add new describe.sequential blocks after the existing categories describe blocks.
For tests that need a category in the DB (create + edit lookups), insert with:
const [cat] = await ctx.db.insert(categories).values({
did: ctx.config.forumDid,
rkey: "tid-test-cat",
cid: "bafycat",
name: "Test Category",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
indexedAt: new Date(),
}).returning({ id: categories.id });
const categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`;
For tests that need a board in the DB (edit + delete), insert with:
const [brd] = await ctx.db.insert(boards).values({
did: ctx.config.forumDid,
rkey: "tid-test-board",
cid: "bafyboard",
name: "Original Name",
description: "Original description",
sortOrder: 1,
categoryId: cat.id,
categoryUri,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
indexedAt: new Date(),
}).returning({ id: boards.id });
const boardId = brd.id.toString();
Task 1: POST /api/admin/boards — failing tests#
Files:
- Modify:
apps/appview/src/routes/__tests__/admin.test.ts(append after line ~1450, after the closing});of the DELETE categories describe block)
Step 1: Write the failing tests
Add this new describe block to the end of the describe.sequential("Admin Routes", ...) block (just before the outer closing });):
describe.sequential("POST /api/admin/boards", () => {
let categoryUri: string;
beforeEach(async () => {
await ctx.cleanDatabase();
mockUser = { did: "did:plc:test-admin" };
mockPutRecord.mockClear();
mockDeleteRecord.mockClear();
mockPutRecord.mockResolvedValue({
data: {
uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid123`,
cid: "bafyboard",
},
});
// Insert a category the tests can reference
await ctx.db.insert(categories).values({
did: ctx.config.forumDid,
rkey: "tid-test-cat",
cid: "bafycat",
name: "Test Category",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
indexedAt: new Date(),
});
categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`;
});
it("creates board with valid body → 201 and putRecord called with categoryRef", async () => {
const res = await app.request("/api/admin/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "General Chat", description: "Talk here.", sortOrder: 1, categoryUri }),
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.uri).toContain("/space.atbb.forum.board/");
expect(data.cid).toBe("bafyboard");
expect(mockPutRecord).toHaveBeenCalledWith(
expect.objectContaining({
repo: ctx.config.forumDid,
collection: "space.atbb.forum.board",
rkey: expect.any(String),
record: expect.objectContaining({
$type: "space.atbb.forum.board",
name: "General Chat",
description: "Talk here.",
sortOrder: 1,
category: { category: { uri: categoryUri, cid: "bafycat" } },
createdAt: expect.any(String),
}),
})
);
});
it("creates board without optional fields → 201", async () => {
const res = await app.request("/api/admin/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Minimal", categoryUri }),
});
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/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ categoryUri }),
});
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/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: " ", categoryUri }),
});
expect(res.status).toBe(400);
expect(mockPutRecord).not.toHaveBeenCalled();
});
it("returns 400 when categoryUri is missing → no PDS write", async () => {
const res = await app.request("/api/admin/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test Board" }),
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("categoryUri");
expect(mockPutRecord).not.toHaveBeenCalled();
});
it("returns 404 when categoryUri references unknown category → no PDS write", async () => {
const res = await app.request("/api/admin/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test Board", categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/unknown999` }),
});
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toContain("Category not found");
expect(mockPutRecord).not.toHaveBeenCalled();
});
it("returns 400 for malformed JSON", async () => {
const res = await app.request("/api/admin/boards", {
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/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test", categoryUri }),
});
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/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test", categoryUri }),
});
expect(res.status).toBe(503);
const data = await res.json();
expect(data.error).toContain("Unable to reach external service");
expect(mockPutRecord).toHaveBeenCalled();
});
it("returns 500 when ForumAgent unavailable", async () => {
ctx.forumAgent = null;
const res = await app.request("/api/admin/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test", categoryUri }),
});
expect(res.status).toBe(500);
const data = await res.json();
expect(data.error).toContain("Forum agent not available");
});
it("returns 403 when user lacks manageCategories permission", async () => {
const { requirePermission } = await import("../../middleware/permissions.js");
const mockRequirePermission = requirePermission as any;
mockRequirePermission.mockImplementation(() => async (c: any) => {
return c.json({ error: "Forbidden" }, 403);
});
const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
const res = await testApp.request("/api/admin/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test", categoryUri }),
});
expect(res.status).toBe(403);
expect(mockPutRecord).not.toHaveBeenCalled();
mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
await next();
});
});
});
Step 2: Run tests to verify they fail
export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PASS|Error|✓|×|POST.*boards" | head -30
Expected: Tests fail with "Cannot find route" or similar.
Step 3: Commit the failing tests
git add apps/appview/src/routes/__tests__/admin.test.ts
git commit -m "test(appview): add failing tests for POST /api/admin/boards (ATB-45)"
Task 2: POST /api/admin/boards — implementation#
Files:
- Modify:
apps/appview/src/routes/admin.ts(add beforereturn app;at the end, around line 701)
Step 1: Add the POST /api/admin/boards endpoint
Insert the following handler before the return app; line:
/**
* POST /api/admin/boards
*
* Create a new forum board within a category. Fetches the category's CID from DB
* to build the categoryRef strongRef required by the lexicon. Writes
* space.atbb.forum.board to the Forum DID's PDS via putRecord.
* The firehose indexer creates the DB row asynchronously.
*/
app.post(
"/boards",
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, categoryUri } = body;
if (typeof name !== "string" || name.trim().length === 0) {
return c.json({ error: "name is required and must be a non-empty string" }, 400);
}
if (typeof categoryUri !== "string" || !categoryUri.startsWith("at://")) {
return c.json({ error: "categoryUri is required and must be a valid AT URI" }, 400);
}
// Derive rkey from the categoryUri to look up the category in the DB
const categoryRkey = categoryUri.split("/").pop();
let category: typeof categories.$inferSelect;
try {
const [row] = await ctx.db
.select()
.from(categories)
.where(
and(
eq(categories.did, ctx.config.forumDid),
eq(categories.rkey, categoryRkey ?? "")
)
)
.limit(1);
if (!row) {
return c.json({ error: "Category not found" }, 404);
}
category = row;
} catch (error) {
return handleRouteError(c, error, "Failed to look up category", {
operation: "POST /api/admin/boards",
logger: ctx.logger,
categoryUri,
});
}
const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/boards");
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.board",
rkey,
record: {
$type: "space.atbb.forum.board",
name: name.trim(),
...(typeof description === "string" && { description: description.trim() }),
...(Number.isInteger(sortOrder) && sortOrder >= 0 && { sortOrder }),
category: { category: { uri: categoryUri, cid: category.cid } },
createdAt: now,
},
});
return c.json({ uri: result.data.uri, cid: result.data.cid }, 201);
} catch (error) {
return handleRouteError(c, error, "Failed to create board", {
operation: "POST /api/admin/boards",
logger: ctx.logger,
categoryUri,
});
}
}
);
Step 2: Run tests to verify they pass
export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PASS|POST.*boards|✓|×" | head -30
Expected: All POST /api/admin/boards tests PASS.
Step 3: Commit
git add apps/appview/src/routes/admin.ts
git commit -m "feat(appview): POST /api/admin/boards create endpoint (ATB-45)"
Task 3: PUT /api/admin/boards/:id — failing tests#
Files:
- Modify:
apps/appview/src/routes/__tests__/admin.test.ts(append after the POST boards describe block)
Step 1: Write the failing tests
describe.sequential("PUT /api/admin/boards/:id", () => {
let boardId: string;
let categoryUri: string;
beforeEach(async () => {
await ctx.cleanDatabase();
mockUser = { did: "did:plc:test-admin" };
mockPutRecord.mockClear();
mockDeleteRecord.mockClear();
mockPutRecord.mockResolvedValue({
data: {
uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`,
cid: "bafyboardupdated",
},
});
// Insert a category and a board
const [cat] = await ctx.db.insert(categories).values({
did: ctx.config.forumDid,
rkey: "tid-test-cat",
cid: "bafycat",
name: "Test Category",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
indexedAt: new Date(),
}).returning({ id: categories.id });
categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`;
const [brd] = await ctx.db.insert(boards).values({
did: ctx.config.forumDid,
rkey: "tid-test-board",
cid: "bafyboard",
name: "Original Name",
description: "Original description",
sortOrder: 1,
categoryId: cat.id,
categoryUri,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
indexedAt: new Date(),
}).returning({ id: boards.id });
boardId = brd.id.toString();
});
it("updates board with all fields → 200 and putRecord called with same rkey", async () => {
const res = await app.request(`/api/admin/boards/${boardId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Renamed Board", description: "New description", sortOrder: 2 }),
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.uri).toContain("/space.atbb.forum.board/");
expect(data.cid).toBe("bafyboardupdated");
expect(mockPutRecord).toHaveBeenCalledWith(
expect.objectContaining({
repo: ctx.config.forumDid,
collection: "space.atbb.forum.board",
rkey: "tid-test-board",
record: expect.objectContaining({
$type: "space.atbb.forum.board",
name: "Renamed Board",
description: "New description",
sortOrder: 2,
category: { category: { uri: categoryUri, cid: "bafycat" } },
}),
})
);
});
it("updates board without optional fields → falls back to existing values", async () => {
const res = await app.request(`/api/admin/boards/${boardId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Renamed Only" }),
});
expect(res.status).toBe(200);
expect(mockPutRecord).toHaveBeenCalledWith(
expect.objectContaining({
record: expect.objectContaining({
name: "Renamed Only",
description: "Original description",
sortOrder: 1,
}),
})
);
});
it("returns 400 when name is missing", async () => {
const res = await app.request(`/api/admin/boards/${boardId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: "No name" }),
});
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", async () => {
const res = await app.request(`/api/admin/boards/${boardId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: " " }),
});
expect(res.status).toBe(400);
expect(mockPutRecord).not.toHaveBeenCalled();
});
it("returns 400 for non-numeric ID", async () => {
const res = await app.request("/api/admin/boards/not-a-number", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test" }),
});
expect(res.status).toBe(400);
expect(mockPutRecord).not.toHaveBeenCalled();
});
it("returns 404 when board not found", async () => {
const res = await app.request("/api/admin/boards/99999", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test" }),
});
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toContain("Board not found");
expect(mockPutRecord).not.toHaveBeenCalled();
});
it("returns 400 for malformed JSON", async () => {
const res = await app.request(`/api/admin/boards/${boardId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: "{ bad json }",
});
expect(res.status).toBe(400);
expect(mockPutRecord).not.toHaveBeenCalled();
});
it("returns 401 when unauthenticated", async () => {
mockUser = null;
const res = await app.request(`/api/admin/boards/${boardId}`, {
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/boards/${boardId}`, {
method: "PUT",
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/boards/${boardId}`, {
method: "PUT",
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");
});
it("returns 403 when user lacks manageCategories permission", async () => {
const { requirePermission } = await import("../../middleware/permissions.js");
const mockRequirePermission = requirePermission as any;
mockRequirePermission.mockImplementation(() => async (c: any) => {
return c.json({ error: "Forbidden" }, 403);
});
const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
const res = await testApp.request(`/api/admin/boards/${boardId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test" }),
});
expect(res.status).toBe(403);
expect(mockPutRecord).not.toHaveBeenCalled();
mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
await next();
});
});
});
Step 2: Run tests to verify they fail
export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PUT.*boards|×" | head -20
Expected: PUT /api/admin/boards tests FAIL.
Step 3: Commit
git add apps/appview/src/routes/__tests__/admin.test.ts
git commit -m "test(appview): add failing tests for PUT /api/admin/boards/:id (ATB-45)"
Task 4: PUT /api/admin/boards/:id — implementation#
Files:
- Modify:
apps/appview/src/routes/admin.ts(add after the POST /boards handler, beforereturn app;)
Step 1: Add the PUT /api/admin/boards/:id endpoint
Note: The boards table stores categoryUri and categoryId. The edit endpoint re-uses the existing categoryUri and fetches the category CID. This avoids clients being able to secretly reparent a board by passing a new categoryUri on edit (category changes would need a dedicated reparent operation).
/**
* PUT /api/admin/boards/:id
*
* Update an existing board's name, description, and sortOrder.
* Fetches existing rkey + categoryUri from DB, then putRecord with updated fields.
* Preserves the original categoryRef and createdAt.
* The firehose indexer updates the DB row asynchronously.
*/
app.put(
"/boards/: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 board 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 board: typeof boards.$inferSelect;
try {
const [row] = await ctx.db
.select()
.from(boards)
.where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid)))
.limit(1);
if (!row) {
return c.json({ error: "Board not found" }, 404);
}
board = row;
} catch (error) {
return handleRouteError(c, error, "Failed to look up board", {
operation: "PUT /api/admin/boards/:id",
logger: ctx.logger,
id: idParam,
});
}
// Fetch category CID to build the categoryRef strongRef
let categoryCid: string;
try {
const categoryRkey = board.categoryUri.split("/").pop() ?? "";
const [cat] = await ctx.db
.select({ cid: categories.cid })
.from(categories)
.where(
and(
eq(categories.did, ctx.config.forumDid),
eq(categories.rkey, categoryRkey)
)
)
.limit(1);
if (!cat) {
return c.json({ error: "Category not found" }, 404);
}
categoryCid = cat.cid;
} catch (error) {
return handleRouteError(c, error, "Failed to look up category", {
operation: "PUT /api/admin/boards/:id",
logger: ctx.logger,
id: idParam,
});
}
const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/boards/:id");
if (agentError) return agentError;
// putRecord is a full replacement — fall back to existing values for
// optional fields not provided in the request body, to avoid data loss.
const resolvedDescription = typeof description === "string"
? description.trim()
: board.description;
const resolvedSortOrder = (Number.isInteger(sortOrder) && sortOrder >= 0)
? sortOrder
: board.sortOrder;
try {
const result = await agent.com.atproto.repo.putRecord({
repo: ctx.config.forumDid,
collection: "space.atbb.forum.board",
rkey: board.rkey,
record: {
$type: "space.atbb.forum.board",
name: name.trim(),
...(resolvedDescription != null && { description: resolvedDescription }),
...(resolvedSortOrder != null && { sortOrder: resolvedSortOrder }),
category: { category: { uri: board.categoryUri, cid: categoryCid } },
createdAt: board.createdAt.toISOString(),
},
});
return c.json({ uri: result.data.uri, cid: result.data.cid });
} catch (error) {
return handleRouteError(c, error, "Failed to update board", {
operation: "PUT /api/admin/boards/:id",
logger: ctx.logger,
id: idParam,
});
}
}
);
Step 2: Run tests to verify they pass
export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PASS|PUT.*boards|✓|×" | head -30
Expected: All PUT /api/admin/boards tests PASS.
Step 3: Commit
git add apps/appview/src/routes/admin.ts
git commit -m "feat(appview): PUT /api/admin/boards/:id update endpoint (ATB-45)"
Task 5: DELETE /api/admin/boards/:id — failing tests#
Files:
- Modify:
apps/appview/src/routes/__tests__/admin.test.ts(append after the PUT boards describe block)
Step 1: Write the failing tests
describe.sequential("DELETE /api/admin/boards/:id", () => {
let boardId: string;
let categoryUri: string;
beforeEach(async () => {
await ctx.cleanDatabase();
mockUser = { did: "did:plc:test-admin" };
mockPutRecord.mockClear();
mockDeleteRecord.mockClear();
mockDeleteRecord.mockResolvedValue({});
// Insert a category and a board
const [cat] = await ctx.db.insert(categories).values({
did: ctx.config.forumDid,
rkey: "tid-test-cat",
cid: "bafycat",
name: "Test Category",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
indexedAt: new Date(),
}).returning({ id: categories.id });
categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`;
const [brd] = await ctx.db.insert(boards).values({
did: ctx.config.forumDid,
rkey: "tid-test-board",
cid: "bafyboard",
name: "Test Board",
categoryId: cat.id,
categoryUri,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
indexedAt: new Date(),
}).returning({ id: boards.id });
boardId = brd.id.toString();
});
it("deletes empty board → 200 and deleteRecord called", async () => {
const res = await app.request(`/api/admin/boards/${boardId}`, {
method: "DELETE",
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
expect(mockDeleteRecord).toHaveBeenCalledWith(
expect.objectContaining({
repo: ctx.config.forumDid,
collection: "space.atbb.forum.board",
rkey: "tid-test-board",
})
);
});
it("returns 409 when board has posts → deleteRecord NOT called", async () => {
// Insert a user and a post referencing this board
await ctx.db.insert(users).values({
did: "did:plc:test-user",
handle: "testuser.bsky.social",
indexedAt: new Date(),
});
const [brd] = await ctx.db.select().from(boards).where(eq(boards.rkey, "tid-test-board")).limit(1);
// Insert a post with boardId set
await ctx.db.insert(posts).values({
did: "did:plc:test-user",
rkey: "tid-test-post",
cid: "bafypost",
text: "Hello world",
boardId: brd.id,
boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`,
forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
createdAt: new Date(),
indexedAt: new Date(),
});
const res = await app.request(`/api/admin/boards/${boardId}`, {
method: "DELETE",
});
expect(res.status).toBe(409);
const data = await res.json();
expect(data.error).toContain("posts");
expect(mockDeleteRecord).not.toHaveBeenCalled();
});
it("returns 400 for non-numeric ID", async () => {
const res = await app.request("/api/admin/boards/not-a-number", {
method: "DELETE",
});
expect(res.status).toBe(400);
expect(mockDeleteRecord).not.toHaveBeenCalled();
});
it("returns 404 when board not found", async () => {
const res = await app.request("/api/admin/boards/99999", {
method: "DELETE",
});
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toContain("Board not found");
expect(mockDeleteRecord).not.toHaveBeenCalled();
});
it("returns 401 when unauthenticated", async () => {
mockUser = null;
const res = await app.request(`/api/admin/boards/${boardId}`, {
method: "DELETE",
});
expect(res.status).toBe(401);
expect(mockDeleteRecord).not.toHaveBeenCalled();
});
it("returns 503 when PDS network error", async () => {
mockDeleteRecord.mockRejectedValue(new Error("fetch failed"));
const res = await app.request(`/api/admin/boards/${boardId}`, {
method: "DELETE",
});
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/boards/${boardId}`, {
method: "DELETE",
});
expect(res.status).toBe(500);
const data = await res.json();
expect(data.error).toContain("Forum agent not available");
});
it("returns 403 when user lacks manageCategories permission", async () => {
const { requirePermission } = await import("../../middleware/permissions.js");
const mockRequirePermission = requirePermission as any;
mockRequirePermission.mockImplementation(() => async (c: any) => {
return c.json({ error: "Forbidden" }, 403);
});
const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
const res = await testApp.request(`/api/admin/boards/${boardId}`, {
method: "DELETE",
});
expect(res.status).toBe(403);
expect(mockDeleteRecord).not.toHaveBeenCalled();
mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
await next();
});
});
});
Important: The DELETE test that inserts a post needs to import posts from @atbb/db. Check the import at the top of the test file — if posts is not already imported, add it to the existing import:
import { memberships, roles, rolePermissions, users, forums, categories, boards, posts } from "@atbb/db";
Step 2: Run tests to verify they fail
export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|DELETE.*boards|×" | head -20
Expected: DELETE /api/admin/boards tests FAIL.
Step 3: Commit
git add apps/appview/src/routes/__tests__/admin.test.ts
git commit -m "test(appview): add failing tests for DELETE /api/admin/boards/:id (ATB-45)"
Task 6: DELETE /api/admin/boards/:id — implementation#
Files:
- Modify:
apps/appview/src/routes/admin.ts(add after the PUT /boards/:id handler, beforereturn app;)
Step 1: Add the DELETE /api/admin/boards/:id endpoint
Also add posts to the existing Drizzle import at the top of the file:
import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts } from "@atbb/db";
Then add the handler:
/**
* DELETE /api/admin/boards/:id
*
* Delete a board. Pre-flight: refuses with 409 if any posts have boardId
* pointing to this board. If clear, calls deleteRecord on the Forum DID's PDS.
* The firehose indexer removes the DB row asynchronously.
*/
app.delete(
"/boards/: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 board ID" }, 400);
}
let board: typeof boards.$inferSelect;
try {
const [row] = await ctx.db
.select()
.from(boards)
.where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid)))
.limit(1);
if (!row) {
return c.json({ error: "Board not found" }, 404);
}
board = row;
} catch (error) {
return handleRouteError(c, error, "Failed to look up board", {
operation: "DELETE /api/admin/boards/:id",
logger: ctx.logger,
id: idParam,
});
}
// Pre-flight: refuse if any posts reference this board
try {
const [postCount] = await ctx.db
.select({ count: count() })
.from(posts)
.where(eq(posts.boardId, id));
if (postCount && postCount.count > 0) {
return c.json(
{ error: "Cannot delete board with posts. Remove all posts first." },
409
);
}
} catch (error) {
return handleRouteError(c, error, "Failed to check board posts", {
operation: "DELETE /api/admin/boards/:id",
logger: ctx.logger,
id: idParam,
});
}
const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/boards/:id");
if (agentError) return agentError;
try {
await agent.com.atproto.repo.deleteRecord({
repo: ctx.config.forumDid,
collection: "space.atbb.forum.board",
rkey: board.rkey,
});
return c.json({ success: true });
} catch (error) {
return handleRouteError(c, error, "Failed to delete board", {
operation: "DELETE /api/admin/boards/:id",
logger: ctx.logger,
id: idParam,
});
}
}
);
Step 2: Run all admin tests to verify everything passes
export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -20
Expected: All tests PASS, no failures.
Step 3: Run the full test suite
export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run 2>&1 | tail -20
Expected: All tests PASS.
Step 4: Commit
git add apps/appview/src/routes/admin.ts
git commit -m "feat(appview): DELETE /api/admin/boards/:id delete endpoint (ATB-45)"
Task 7: Bruno collection — board management#
Files:
- Create:
bruno/AppView API/Admin/Create Board.bru - Create:
bruno/AppView API/Admin/Update Board.bru - Create:
bruno/AppView API/Admin/Delete Board.bru
Step 1: Create Create Board.bru
meta {
name: Create Board
type: http
seq: 13
}
post {
url: {{appview_url}}/api/admin/boards
}
body:json {
{
"name": "General Chat",
"description": "Talk about anything.",
"sortOrder": 1,
"categoryUri": "{{category_uri}}"
}
}
assert {
res.status: eq 201
res.body.uri: isDefined
res.body.cid: isDefined
}
docs {
Create a new forum board within a category. Fetches the category's CID from DB
to build the categoryRef strongRef. Writes space.atbb.forum.board to the Forum
DID's PDS. The firehose indexer creates the DB row asynchronously.
**Requires:** space.atbb.permission.manageCategories
Body:
- name (required): Board display name
- categoryUri (required): AT URI of the parent category
- description (optional): Short description
- sortOrder (optional): Numeric sort position (lower = first)
Returns (201):
{
"uri": "at://did:plc:.../space.atbb.forum.board/abc123",
"cid": "bafyrei..."
}
Error codes:
- 400: Missing or empty name, missing categoryUri, malformed JSON
- 401: Not authenticated
- 403: Missing manageCategories permission
- 404: categoryUri references unknown category
- 500: ForumAgent not configured
- 503: PDS network error
}
Step 2: Create Update Board.bru
meta {
name: Update Board
type: http
seq: 14
}
put {
url: {{appview_url}}/api/admin/boards/:id
}
params:path {
id: {{board_id}}
}
body:json {
{
"name": "General Chat (renamed)",
"description": "Updated description.",
"sortOrder": 2
}
}
assert {
res.status: eq 200
res.body.uri: isDefined
res.body.cid: isDefined
}
docs {
Update an existing forum board's name, description, and sortOrder.
Fetches existing rkey and categoryRef from DB, calls putRecord with updated
fields preserving the original category and createdAt.
**Requires:** space.atbb.permission.manageCategories
Path params:
- id: Board database ID (bigint as string)
Body:
- name (required): New display name
- description (optional): New description (falls back to existing if omitted)
- sortOrder (optional): New sort position (falls back to existing if omitted)
Returns (200):
{
"uri": "at://did:plc:.../space.atbb.forum.board/abc123",
"cid": "bafyrei..."
}
Error codes:
- 400: Missing name, empty name, invalid ID format, malformed JSON
- 401: Not authenticated
- 403: Missing manageCategories permission
- 404: Board not found
- 500: ForumAgent not configured
- 503: PDS network error
}
Step 3: Create Delete Board.bru
meta {
name: Delete Board
type: http
seq: 15
}
delete {
url: {{appview_url}}/api/admin/boards/:id
}
params:path {
id: {{board_id}}
}
assert {
res.status: eq 200
res.body.success: isTrue
}
docs {
Delete a forum board. Pre-flight check refuses with 409 if any posts reference
this board. 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: Board database ID (bigint as string)
Returns (200):
{
"success": true
}
Error codes:
- 400: Invalid ID format
- 401: Not authenticated
- 403: Missing manageCategories permission
- 404: Board not found
- 409: Board has posts — remove them first
- 500: ForumAgent not configured
- 503: PDS network error
}
Step 4: Commit
git add "bruno/AppView API/Admin/Create Board.bru" "bruno/AppView API/Admin/Update Board.bru" "bruno/AppView API/Admin/Delete Board.bru"
git commit -m "docs(bruno): add board management API collection (ATB-45)"
Task 8: Lint, full test run, and verification#
Step 1: Run lint fix
export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview lint:fix
Expected: No unfixable errors.
Step 2: Run full test suite
export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run 2>&1 | tail -20
Expected: All tests PASS.
Step 3: Update Linear issue
Mark ATB-45 as "In Progress" in Linear, then as "Done" once the branch is ready for review.
Step 4: Request code review
Follow the commit-push-pr skill to push and open a PR:
git log --oneline origin/main..HEAD
Verify all commits are present, then push and open PR against main.