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

feat(appview): DELETE /api/admin/boards/:id delete endpoint (ATB-45)

Pre-flight refuses with 409 if any posts reference the board (via posts.boardId).
Also fixes test error messages to use "Database connection lost" (matching isDatabaseError keywords) for consistent 503 classification.

+83 -3
+2 -2
apps/appview/src/routes/__tests__/admin.test.ts
··· 2006 2006 2007 2007 it("returns 503 when board lookup query fails", async () => { 2008 2008 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 2009 - throw new Error("DB connection lost"); 2009 + throw new Error("Database connection lost"); 2010 2010 }); 2011 2011 2012 2012 const res = await app.request(`/api/admin/boards/${boardId}`, { ··· 2031 2031 return (originalSelect as any)(...args); 2032 2032 } 2033 2033 // Second call: post count preflight — throw DB error 2034 - throw new Error("DB connection lost"); 2034 + throw new Error("Database connection lost"); 2035 2035 }); 2036 2036 2037 2037 const res = await app.request(`/api/admin/boards/${boardId}`, {
+81 -1
apps/appview/src/routes/admin.ts
··· 3 3 import type { Variables } from "../types.js"; 4 4 import { requireAuth } from "../middleware/auth.js"; 5 5 import { requirePermission, getUserRole } from "../middleware/permissions.js"; 6 - import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards } from "@atbb/db"; 6 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts } from "@atbb/db"; 7 7 import { eq, and, sql, asc, count } from "drizzle-orm"; 8 8 import { isProgrammingError } from "../lib/errors.js"; 9 9 import { BackfillStatus } from "../lib/backfill-manager.js"; ··· 893 893 } catch (error) { 894 894 return handleRouteError(c, error, "Failed to update board", { 895 895 operation: "PUT /api/admin/boards/:id", 896 + logger: ctx.logger, 897 + id: idParam, 898 + }); 899 + } 900 + } 901 + ); 902 + 903 + /** 904 + * DELETE /api/admin/boards/:id 905 + * 906 + * Delete a board. Pre-flight: refuses with 409 if any posts have boardId 907 + * pointing to this board. If clear, calls deleteRecord on the Forum DID's PDS. 908 + * The firehose indexer removes the DB row asynchronously. 909 + */ 910 + app.delete( 911 + "/boards/:id", 912 + requireAuth(ctx), 913 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 914 + async (c) => { 915 + const idParam = c.req.param("id"); 916 + const id = parseBigIntParam(idParam); 917 + if (id === null) { 918 + return c.json({ error: "Invalid board ID" }, 400); 919 + } 920 + 921 + let board: typeof boards.$inferSelect; 922 + try { 923 + const [row] = await ctx.db 924 + .select() 925 + .from(boards) 926 + .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid))) 927 + .limit(1); 928 + 929 + if (!row) { 930 + return c.json({ error: "Board not found" }, 404); 931 + } 932 + board = row; 933 + } catch (error) { 934 + return handleRouteError(c, error, "Failed to look up board", { 935 + operation: "DELETE /api/admin/boards/:id", 936 + logger: ctx.logger, 937 + id: idParam, 938 + }); 939 + } 940 + 941 + // Pre-flight: refuse if any posts reference this board 942 + try { 943 + const [postCount] = await ctx.db 944 + .select({ count: count() }) 945 + .from(posts) 946 + .where(eq(posts.boardId, id)); 947 + 948 + if (postCount && postCount.count > 0) { 949 + return c.json( 950 + { error: "Cannot delete board with posts. Remove all posts first." }, 951 + 409 952 + ); 953 + } 954 + } catch (error) { 955 + return handleRouteError(c, error, "Failed to check board posts", { 956 + operation: "DELETE /api/admin/boards/:id", 957 + logger: ctx.logger, 958 + id: idParam, 959 + }); 960 + } 961 + 962 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/boards/:id"); 963 + if (agentError) return agentError; 964 + 965 + try { 966 + await agent.com.atproto.repo.deleteRecord({ 967 + repo: ctx.config.forumDid, 968 + collection: "space.atbb.forum.board", 969 + rkey: board.rkey, 970 + }); 971 + 972 + return c.json({ success: true }); 973 + } catch (error) { 974 + return handleRouteError(c, error, "Failed to delete board", { 975 + operation: "DELETE /api/admin/boards/:id", 896 976 logger: ctx.logger, 897 977 id: idParam, 898 978 });