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
1import { Hono } from "hono";
2import type { AppContext } from "../lib/app-context.js";
3import type { Variables } from "../types.js";
4import { requireAuth } from "../middleware/auth.js";
5import { requirePermission, requireAnyPermission, getUserRole } from "../middleware/permissions.js";
6import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions } from "@atbb/db";
7import { eq, and, sql, asc, desc, count } from "drizzle-orm";
8import { alias } from "drizzle-orm/pg-core";
9import { isProgrammingError } from "../lib/errors.js";
10import { BackfillStatus } from "../lib/backfill-manager.js";
11import { CursorManager } from "../lib/cursor-manager.js";
12import {
13 handleRouteError,
14 safeParseJsonBody,
15 getForumAgentOrError,
16} from "../lib/route-errors.js";
17import { TID } from "@atproto/common-web";
18import { parseBigIntParam } from "./helpers.js";
19
20export function createAdminRoutes(ctx: AppContext) {
21 const app = new Hono<{ Variables: Variables }>();
22
23 /**
24 * POST /api/admin/members/:did/role
25 *
26 * Assign a role to a forum member.
27 */
28 app.post(
29 "/members/:did/role",
30 requireAuth(ctx),
31 requirePermission(ctx, "space.atbb.permission.manageRoles"),
32 async (c) => {
33 const targetDid = c.req.param("did");
34 const user = c.get("user")!;
35
36 // Parse and validate request body
37 const { body, error: parseError } = await safeParseJsonBody(c);
38 if (parseError) return parseError;
39
40 const { roleUri } = body;
41
42 if (typeof roleUri !== "string") {
43 return c.json({ error: "roleUri is required and must be a string" }, 400);
44 }
45
46 // Validate roleUri format
47 if (!roleUri.startsWith("at://") || !roleUri.includes("/space.atbb.forum.role/")) {
48 return c.json({ error: "Invalid roleUri format" }, 400);
49 }
50
51 // Extract role rkey from roleUri
52 const roleRkey = roleUri.split("/").pop();
53 if (!roleRkey) {
54 return c.json({ error: "Invalid roleUri format" }, 400);
55 }
56
57 try {
58 // Validate role exists
59 const [role] = await ctx.db
60 .select()
61 .from(roles)
62 .where(
63 and(
64 eq(roles.did, ctx.config.forumDid),
65 eq(roles.rkey, roleRkey)
66 )
67 )
68 .limit(1);
69
70 if (!role) {
71 return c.json({ error: "Role not found" }, 404);
72 }
73
74 // Priority check: Can't assign role with equal or higher authority
75 const assignerRole = await getUserRole(ctx, user.did);
76 if (!assignerRole) {
77 return c.json({ error: "You do not have a role assigned" }, 403);
78 }
79
80 if (role.priority <= assignerRole.priority) {
81 return c.json({
82 error: "Cannot assign role with equal or higher authority",
83 }, 403);
84 }
85
86 // Get target user's membership
87 const [membership] = await ctx.db
88 .select()
89 .from(memberships)
90 .where(eq(memberships.did, targetDid))
91 .limit(1);
92
93 if (!membership) {
94 return c.json({ error: "User is not a member of this forum" }, 404);
95 }
96
97 // Fetch forum CID for membership record
98 const [forum] = await ctx.db
99 .select({ cid: forums.cid })
100 .from(forums)
101 .where(eq(forums.did, ctx.config.forumDid))
102 .limit(1);
103
104 if (!forum) {
105 return c.json({ error: "Forum record not found in database" }, 500);
106 }
107
108 // Get ForumAgent for PDS write operations
109 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/members/:did/role");
110 if (agentError) return agentError;
111
112 try {
113 // Update membership record on user's PDS using ForumAgent
114 await agent.com.atproto.repo.putRecord({
115 repo: targetDid,
116 collection: "space.atbb.membership",
117 rkey: membership.rkey,
118 record: {
119 $type: "space.atbb.membership",
120 forum: { forum: { uri: membership.forumUri, cid: forum.cid } },
121 role: { role: { uri: roleUri, cid: role.cid } },
122 joinedAt: membership.joinedAt?.toISOString(),
123 createdAt: membership.createdAt.toISOString(),
124 },
125 });
126
127 return c.json({
128 success: true,
129 roleAssigned: role.name,
130 targetDid,
131 });
132 } catch (error) {
133 return handleRouteError(c, error, "Failed to assign role", {
134 operation: "POST /api/admin/members/:did/role",
135 logger: ctx.logger,
136 targetDid,
137 roleUri,
138 });
139 }
140 } catch (error) {
141 return handleRouteError(c, error, "Failed to process role assignment", {
142 operation: "POST /api/admin/members/:did/role",
143 logger: ctx.logger,
144 targetDid,
145 roleUri,
146 });
147 }
148 }
149 );
150
151 /**
152 * GET /api/admin/roles
153 *
154 * List all available roles for the forum.
155 */
156 app.get(
157 "/roles",
158 requireAuth(ctx),
159 requirePermission(ctx, "space.atbb.permission.manageRoles"),
160 async (c) => {
161 try {
162 const rolesList = await ctx.db
163 .select({
164 id: roles.id,
165 name: roles.name,
166 description: roles.description,
167 priority: roles.priority,
168 rkey: roles.rkey,
169 did: roles.did,
170 })
171 .from(roles)
172 .where(eq(roles.did, ctx.config.forumDid))
173 .orderBy(asc(roles.priority));
174
175 const rolesWithPermissions = await Promise.all(
176 rolesList.map(async (role) => {
177 const perms = await ctx.db
178 .select({ permission: rolePermissions.permission })
179 .from(rolePermissions)
180 .where(eq(rolePermissions.roleId, role.id));
181 return {
182 id: role.id.toString(),
183 name: role.name,
184 description: role.description,
185 permissions: perms.map((p) => p.permission),
186 priority: role.priority,
187 uri: `at://${role.did}/space.atbb.forum.role/${role.rkey}`,
188 };
189 })
190 );
191
192 return c.json({ roles: rolesWithPermissions });
193 } catch (error) {
194 return handleRouteError(c, error, "Failed to retrieve roles", {
195 operation: "GET /api/admin/roles",
196 logger: ctx.logger,
197 });
198 }
199 }
200 );
201
202 /**
203 * GET /api/admin/members
204 *
205 * List all forum members with their assigned roles.
206 */
207 app.get(
208 "/members",
209 requireAuth(ctx),
210 requirePermission(ctx, "space.atbb.permission.manageMembers"),
211 async (c) => {
212 try {
213 const membersList = await ctx.db
214 .select({
215 did: memberships.did,
216 handle: users.handle,
217 role: roles.name,
218 roleUri: memberships.roleUri,
219 joinedAt: memberships.joinedAt,
220 })
221 .from(memberships)
222 .leftJoin(users, eq(memberships.did, users.did))
223 .leftJoin(
224 roles,
225 sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}`
226 )
227 .where(eq(memberships.forumUri, `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`))
228 .orderBy(asc(roles.priority), asc(users.handle))
229 .limit(100);
230
231 return c.json({
232 members: membersList.map(member => ({
233 did: member.did,
234 handle: member.handle || member.did,
235 role: member.role || "Guest",
236 roleUri: member.roleUri,
237 joinedAt: member.joinedAt?.toISOString(),
238 })),
239 isTruncated: membersList.length === 100,
240 });
241 } catch (error) {
242 return handleRouteError(c, error, "Failed to retrieve members", {
243 operation: "GET /api/admin/members",
244 logger: ctx.logger,
245 });
246 }
247 }
248 );
249
250
251 /**
252 * GET /api/admin/members/me
253 *
254 * Returns the calling user's own membership, role name, and permissions.
255 * Any authenticated user may call this — no special permission required.
256 * Returns 404 if the user has no membership record for this forum.
257 */
258 app.get("/members/me", requireAuth(ctx), async (c) => {
259 const user = c.get("user")!;
260
261 try {
262 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`;
263 const [member] = await ctx.db
264 .select({
265 did: memberships.did,
266 handle: users.handle,
267 roleUri: memberships.roleUri,
268 roleName: roles.name,
269 roleId: roles.id,
270 })
271 .from(memberships)
272 .leftJoin(users, eq(memberships.did, users.did))
273 .leftJoin(
274 roles,
275 sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}`
276 )
277 .where(
278 and(
279 eq(memberships.did, user.did),
280 eq(memberships.forumUri, forumUri)
281 )
282 )
283 .limit(1);
284
285 if (!member) {
286 return c.json({ error: "Membership not found" }, 404);
287 }
288
289 let permissions: string[] = [];
290 if (member.roleId) {
291 const perms = await ctx.db
292 .select({ permission: rolePermissions.permission })
293 .from(rolePermissions)
294 .where(eq(rolePermissions.roleId, member.roleId));
295 permissions = perms.map((p) => p.permission);
296 }
297
298 return c.json({
299 did: member.did,
300 handle: member.handle || user.did,
301 role: member.roleName || "Guest",
302 roleUri: member.roleUri,
303 permissions,
304 });
305 } catch (error) {
306 return handleRouteError(c, error, "Failed to retrieve your membership", {
307 operation: "GET /api/admin/members/me",
308 logger: ctx.logger,
309 did: user.did,
310 });
311 }
312 });
313
314 /**
315 * POST /api/admin/backfill
316 *
317 * Trigger a backfill operation. Runs asynchronously.
318 * Returns 202 Accepted immediately.
319 * Use ?force=catch_up or ?force=full_sync to override gap detection.
320 */
321 app.post(
322 "/backfill",
323 requireAuth(ctx),
324 requirePermission(ctx, "space.atbb.permission.manageForum"),
325 async (c) => {
326 const backfillManager = ctx.backfillManager;
327 if (!backfillManager) {
328 return c.json({ error: "Backfill manager not available" }, 503);
329 }
330
331 if (backfillManager.getIsRunning()) {
332 return c.json({ error: "A backfill is already in progress" }, 409);
333 }
334
335 // Determine backfill type
336 const force = c.req.query("force");
337 let type: BackfillStatus;
338
339 if (force === "catch_up" || force === "full_sync") {
340 type = force === "catch_up" ? BackfillStatus.CatchUp : BackfillStatus.FullSync;
341 } else {
342 try {
343 const cursor = await new CursorManager(ctx.db, ctx.logger).load();
344 type = await backfillManager.checkIfNeeded(cursor);
345 } catch (error) {
346 if (isProgrammingError(error)) throw error;
347 ctx.logger.error("Failed to check backfill status", {
348 event: "backfill.admin_trigger.check_failed",
349 error: error instanceof Error ? error.message : String(error),
350 });
351 return c.json({ error: "Failed to check backfill status. Please try again later." }, 500);
352 }
353
354 if (type === BackfillStatus.NotNeeded) {
355 return c.json({
356 message: "No backfill needed. Use ?force=catch_up or ?force=full_sync to override.",
357 }, 200);
358 }
359 }
360
361 // Create progress row first so we can return the ID immediately in the 202 response
362 let progressId: bigint;
363 try {
364 progressId = await backfillManager.prepareBackfillRow(type);
365 } catch (error) {
366 if (isProgrammingError(error)) throw error;
367 ctx.logger.error("Failed to create backfill row", {
368 event: "backfill.admin_trigger.create_row_failed",
369 error: error instanceof Error ? error.message : String(error),
370 });
371 return c.json({ error: "Failed to start backfill. Please try again later." }, 500);
372 }
373
374 // Fire and forget — don't await so response is immediate
375 backfillManager.performBackfill(type, progressId).catch((err) => {
376 ctx.logger.error("Background backfill failed", {
377 event: "backfill.admin_trigger_failed",
378 backfillId: progressId.toString(),
379 error: err instanceof Error ? err.message : String(err),
380 });
381 });
382
383 return c.json({
384 message: "Backfill started",
385 type,
386 status: "in_progress",
387 id: progressId.toString(),
388 }, 202);
389 }
390 );
391
392 /**
393 * GET /api/admin/backfill/:id
394 *
395 * Get status and progress for a specific backfill by ID.
396 */
397 app.get(
398 "/backfill/:id",
399 requireAuth(ctx),
400 requirePermission(ctx, "space.atbb.permission.manageForum"),
401 async (c) => {
402 const id = c.req.param("id");
403 if (!/^\d+$/.test(id)) {
404 return c.json({ error: "Invalid backfill ID" }, 400);
405 }
406 const parsedId = BigInt(id);
407
408 try {
409 const [row] = await ctx.db
410 .select()
411 .from(backfillProgress)
412 .where(eq(backfillProgress.id, parsedId))
413 .limit(1);
414
415 if (!row) {
416 return c.json({ error: "Backfill not found" }, 404);
417 }
418
419 const [errorCount] = await ctx.db
420 .select({ count: count() })
421 .from(backfillErrors)
422 .where(eq(backfillErrors.backfillId, row.id));
423
424 return c.json({
425 id: row.id.toString(),
426 status: row.status,
427 type: row.backfillType,
428 didsTotal: row.didsTotal,
429 didsProcessed: row.didsProcessed,
430 recordsIndexed: row.recordsIndexed,
431 errorCount: errorCount?.count ?? 0,
432 startedAt: row.startedAt.toISOString(),
433 completedAt: row.completedAt?.toISOString() ?? null,
434 errorMessage: row.errorMessage,
435 });
436 } catch (error) {
437 return handleRouteError(c, error, "Failed to fetch backfill progress", {
438 operation: "GET /api/admin/backfill/:id",
439 logger: ctx.logger,
440 id,
441 });
442 }
443 }
444 );
445
446 /**
447 * GET /api/admin/backfill/:id/errors
448 *
449 * List per-DID errors for a specific backfill.
450 */
451 app.get(
452 "/backfill/:id/errors",
453 requireAuth(ctx),
454 requirePermission(ctx, "space.atbb.permission.manageForum"),
455 async (c) => {
456 const id = c.req.param("id");
457 if (!/^\d+$/.test(id)) {
458 return c.json({ error: "Invalid backfill ID" }, 400);
459 }
460 const parsedId = BigInt(id);
461
462 try {
463 const errors = await ctx.db
464 .select()
465 .from(backfillErrors)
466 .where(eq(backfillErrors.backfillId, parsedId))
467 .orderBy(asc(backfillErrors.createdAt))
468 .limit(1000);
469
470 return c.json({
471 errors: errors.map((e) => ({
472 id: e.id.toString(),
473 did: e.did,
474 collection: e.collection,
475 errorMessage: e.errorMessage,
476 createdAt: e.createdAt.toISOString(),
477 })),
478 });
479 } catch (error) {
480 return handleRouteError(c, error, "Failed to fetch backfill errors", {
481 operation: "GET /api/admin/backfill/:id/errors",
482 logger: ctx.logger,
483 id,
484 });
485 }
486 }
487 );
488
489 /**
490 * POST /api/admin/categories
491 *
492 * Create a new forum category. Writes space.atbb.forum.category to Forum DID's PDS.
493 * The firehose indexer creates the DB row asynchronously.
494 */
495 app.post(
496 "/categories",
497 requireAuth(ctx),
498 requirePermission(ctx, "space.atbb.permission.manageCategories"),
499 async (c) => {
500 const { body, error: parseError } = await safeParseJsonBody(c);
501 if (parseError) return parseError;
502
503 const { name, description, sortOrder } = body;
504
505 if (typeof name !== "string" || name.trim().length === 0) {
506 return c.json({ error: "name is required and must be a non-empty string" }, 400);
507 }
508
509 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/categories");
510 if (agentError) return agentError;
511
512 const rkey = TID.nextStr();
513 const now = new Date().toISOString();
514
515 try {
516 const result = await agent.com.atproto.repo.putRecord({
517 repo: ctx.config.forumDid,
518 collection: "space.atbb.forum.category",
519 rkey,
520 record: {
521 $type: "space.atbb.forum.category",
522 name: name.trim(),
523 ...(typeof description === "string" && { description: description.trim() }),
524 ...(Number.isInteger(sortOrder) && sortOrder >= 0 && { sortOrder }),
525 createdAt: now,
526 },
527 });
528
529 return c.json({ uri: result.data.uri, cid: result.data.cid }, 201);
530 } catch (error) {
531 return handleRouteError(c, error, "Failed to create category", {
532 operation: "POST /api/admin/categories",
533 logger: ctx.logger,
534 });
535 }
536 }
537 );
538
539 /**
540 * PUT /api/admin/categories/:id
541 *
542 * Update an existing category. Fetches existing rkey from DB, calls putRecord
543 * with updated fields preserving the original createdAt.
544 * The firehose indexer updates the DB row asynchronously.
545 */
546 app.put(
547 "/categories/:id",
548 requireAuth(ctx),
549 requirePermission(ctx, "space.atbb.permission.manageCategories"),
550 async (c) => {
551 const idParam = c.req.param("id");
552 const id = parseBigIntParam(idParam);
553 if (id === null) {
554 return c.json({ error: "Invalid category ID" }, 400);
555 }
556
557 const { body, error: parseError } = await safeParseJsonBody(c);
558 if (parseError) return parseError;
559
560 const { name, description, sortOrder } = body;
561
562 if (typeof name !== "string" || name.trim().length === 0) {
563 return c.json({ error: "name is required and must be a non-empty string" }, 400);
564 }
565
566 let category: typeof categories.$inferSelect;
567 try {
568 const [row] = await ctx.db
569 .select()
570 .from(categories)
571 .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid)))
572 .limit(1);
573
574 if (!row) {
575 return c.json({ error: "Category not found" }, 404);
576 }
577 category = row;
578 } catch (error) {
579 return handleRouteError(c, error, "Failed to look up category", {
580 operation: "PUT /api/admin/categories/:id",
581 logger: ctx.logger,
582 id: idParam,
583 });
584 }
585
586 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/categories/:id");
587 if (agentError) return agentError;
588
589 // putRecord is a full replacement — fall back to existing values for
590 // optional fields not provided in the request body, to avoid data loss.
591 const resolvedDescription = typeof description === "string"
592 ? description.trim()
593 : category.description;
594 const resolvedSortOrder = (Number.isInteger(sortOrder) && sortOrder >= 0)
595 ? sortOrder
596 : category.sortOrder;
597
598 try {
599 const result = await agent.com.atproto.repo.putRecord({
600 repo: ctx.config.forumDid,
601 collection: "space.atbb.forum.category",
602 rkey: category.rkey,
603 record: {
604 $type: "space.atbb.forum.category",
605 name: name.trim(),
606 ...(resolvedDescription != null && { description: resolvedDescription }),
607 ...(resolvedSortOrder != null && { sortOrder: resolvedSortOrder }),
608 createdAt: category.createdAt.toISOString(),
609 },
610 });
611
612 return c.json({ uri: result.data.uri, cid: result.data.cid });
613 } catch (error) {
614 return handleRouteError(c, error, "Failed to update category", {
615 operation: "PUT /api/admin/categories/:id",
616 logger: ctx.logger,
617 id: idParam,
618 });
619 }
620 }
621 );
622
623 /**
624 * DELETE /api/admin/categories/:id
625 *
626 * Delete a category. Pre-flight: refuses with 409 if any boards reference this
627 * category in the DB. If clear, calls deleteRecord on the Forum DID's PDS.
628 * The firehose indexer removes the DB row asynchronously.
629 */
630 app.delete(
631 "/categories/:id",
632 requireAuth(ctx),
633 requirePermission(ctx, "space.atbb.permission.manageCategories"),
634 async (c) => {
635 const idParam = c.req.param("id");
636 const id = parseBigIntParam(idParam);
637 if (id === null) {
638 return c.json({ error: "Invalid category ID" }, 400);
639 }
640
641 let category: typeof categories.$inferSelect;
642 try {
643 const [row] = await ctx.db
644 .select()
645 .from(categories)
646 .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid)))
647 .limit(1);
648
649 if (!row) {
650 return c.json({ error: "Category not found" }, 404);
651 }
652 category = row;
653 } catch (error) {
654 return handleRouteError(c, error, "Failed to look up category", {
655 operation: "DELETE /api/admin/categories/:id",
656 logger: ctx.logger,
657 id: idParam,
658 });
659 }
660
661 // Pre-flight: refuse if any boards reference this category
662 try {
663 const [boardCount] = await ctx.db
664 .select({ count: count() })
665 .from(boards)
666 .where(eq(boards.categoryId, id));
667
668 if (boardCount && boardCount.count > 0) {
669 return c.json(
670 { error: "Cannot delete category with boards. Remove all boards first." },
671 409
672 );
673 }
674 } catch (error) {
675 return handleRouteError(c, error, "Failed to check category boards", {
676 operation: "DELETE /api/admin/categories/:id",
677 logger: ctx.logger,
678 id: idParam,
679 });
680 }
681
682 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/categories/:id");
683 if (agentError) return agentError;
684
685 try {
686 await agent.com.atproto.repo.deleteRecord({
687 repo: ctx.config.forumDid,
688 collection: "space.atbb.forum.category",
689 rkey: category.rkey,
690 });
691
692 return c.json({ success: true });
693 } catch (error) {
694 return handleRouteError(c, error, "Failed to delete category", {
695 operation: "DELETE /api/admin/categories/:id",
696 logger: ctx.logger,
697 id: idParam,
698 });
699 }
700 }
701 );
702
703 /**
704 * POST /api/admin/boards
705 *
706 * Create a new forum board within a category. Fetches the category's CID from DB
707 * to build the categoryRef strongRef required by the lexicon. Writes
708 * space.atbb.forum.board to the Forum DID's PDS via putRecord.
709 * The firehose indexer creates the DB row asynchronously.
710 */
711 app.post(
712 "/boards",
713 requireAuth(ctx),
714 requirePermission(ctx, "space.atbb.permission.manageCategories"),
715 async (c) => {
716 const { body, error: parseError } = await safeParseJsonBody(c);
717 if (parseError) return parseError;
718
719 const { name, description, sortOrder, categoryUri } = body;
720
721 if (typeof name !== "string" || name.trim().length === 0) {
722 return c.json({ error: "name is required and must be a non-empty string" }, 400);
723 }
724
725 if (typeof categoryUri !== "string" || !categoryUri.startsWith("at://")) {
726 return c.json({ error: "categoryUri is required and must be a valid AT URI" }, 400);
727 }
728
729 // Derive rkey from the categoryUri to look up the category in the DB
730 const categoryRkey = categoryUri.split("/").pop();
731
732 let category: typeof categories.$inferSelect;
733 try {
734 const [row] = await ctx.db
735 .select()
736 .from(categories)
737 .where(
738 and(
739 eq(categories.did, ctx.config.forumDid),
740 eq(categories.rkey, categoryRkey ?? "")
741 )
742 )
743 .limit(1);
744
745 if (!row) {
746 return c.json({ error: "Category not found" }, 404);
747 }
748 category = row;
749 } catch (error) {
750 return handleRouteError(c, error, "Failed to look up category", {
751 operation: "POST /api/admin/boards",
752 logger: ctx.logger,
753 categoryUri,
754 });
755 }
756
757 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/boards");
758 if (agentError) return agentError;
759
760 const rkey = TID.nextStr();
761 const now = new Date().toISOString();
762
763 try {
764 const result = await agent.com.atproto.repo.putRecord({
765 repo: ctx.config.forumDid,
766 collection: "space.atbb.forum.board",
767 rkey,
768 record: {
769 $type: "space.atbb.forum.board",
770 name: name.trim(),
771 ...(typeof description === "string" && { description: description.trim() }),
772 ...(Number.isInteger(sortOrder) && sortOrder >= 0 && { sortOrder }),
773 category: { category: { uri: categoryUri, cid: category.cid } },
774 createdAt: now,
775 },
776 });
777
778 return c.json({ uri: result.data.uri, cid: result.data.cid }, 201);
779 } catch (error) {
780 return handleRouteError(c, error, "Failed to create board", {
781 operation: "POST /api/admin/boards",
782 logger: ctx.logger,
783 categoryUri,
784 });
785 }
786 }
787 );
788
789 /**
790 * PUT /api/admin/boards/:id
791 *
792 * Update an existing board's name, description, and sortOrder.
793 * Fetches existing rkey + categoryUri from DB, then fetches category CID,
794 * then putRecord with updated fields preserving the original categoryRef and createdAt.
795 * Category cannot be changed on edit (no reparenting).
796 * The firehose indexer updates the DB row asynchronously.
797 */
798 app.put(
799 "/boards/:id",
800 requireAuth(ctx),
801 requirePermission(ctx, "space.atbb.permission.manageCategories"),
802 async (c) => {
803 const idParam = c.req.param("id");
804 const id = parseBigIntParam(idParam);
805 if (id === null) {
806 return c.json({ error: "Invalid board ID" }, 400);
807 }
808
809 const { body, error: parseError } = await safeParseJsonBody(c);
810 if (parseError) return parseError;
811
812 const { name, description, sortOrder } = body;
813
814 if (typeof name !== "string" || name.trim().length === 0) {
815 return c.json({ error: "name is required and must be a non-empty string" }, 400);
816 }
817
818 let board: typeof boards.$inferSelect;
819 try {
820 const [row] = await ctx.db
821 .select()
822 .from(boards)
823 .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid)))
824 .limit(1);
825
826 if (!row) {
827 return c.json({ error: "Board not found" }, 404);
828 }
829 board = row;
830 } catch (error) {
831 return handleRouteError(c, error, "Failed to look up board", {
832 operation: "PUT /api/admin/boards/:id",
833 logger: ctx.logger,
834 id: idParam,
835 });
836 }
837
838 // Fetch category CID to rebuild the categoryRef strongRef.
839 // Always fetch fresh — the category's CID can change after category edits.
840 let categoryCid: string;
841 try {
842 const categoryRkey = board.categoryUri.split("/").pop() ?? "";
843 const [cat] = await ctx.db
844 .select({ cid: categories.cid })
845 .from(categories)
846 .where(
847 and(
848 eq(categories.did, ctx.config.forumDid),
849 eq(categories.rkey, categoryRkey)
850 )
851 )
852 .limit(1);
853
854 if (!cat) {
855 return c.json({ error: "Category not found" }, 404);
856 }
857 categoryCid = cat.cid;
858 } catch (error) {
859 return handleRouteError(c, error, "Failed to look up category", {
860 operation: "PUT /api/admin/boards/:id",
861 logger: ctx.logger,
862 id: idParam,
863 });
864 }
865
866 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/boards/:id");
867 if (agentError) return agentError;
868
869 // putRecord is a full replacement — fall back to existing values for
870 // optional fields not provided in the request body, to avoid data loss.
871 const resolvedDescription = typeof description === "string"
872 ? description.trim()
873 : board.description;
874 const resolvedSortOrder = (Number.isInteger(sortOrder) && sortOrder >= 0)
875 ? sortOrder
876 : board.sortOrder;
877
878 try {
879 const result = await agent.com.atproto.repo.putRecord({
880 repo: ctx.config.forumDid,
881 collection: "space.atbb.forum.board",
882 rkey: board.rkey,
883 record: {
884 $type: "space.atbb.forum.board",
885 name: name.trim(),
886 ...(resolvedDescription != null && { description: resolvedDescription }),
887 ...(resolvedSortOrder != null && { sortOrder: resolvedSortOrder }),
888 category: { category: { uri: board.categoryUri, cid: categoryCid } },
889 createdAt: board.createdAt.toISOString(),
890 },
891 });
892
893 return c.json({ uri: result.data.uri, cid: result.data.cid });
894 } catch (error) {
895 return handleRouteError(c, error, "Failed to update board", {
896 operation: "PUT /api/admin/boards/:id",
897 logger: ctx.logger,
898 id: idParam,
899 });
900 }
901 }
902 );
903
904 /**
905 * DELETE /api/admin/boards/:id
906 *
907 * Delete a board. Pre-flight: refuses with 409 if any posts have boardId
908 * pointing to this board. If clear, calls deleteRecord on the Forum DID's PDS.
909 * The firehose indexer removes the DB row asynchronously.
910 */
911 app.delete(
912 "/boards/:id",
913 requireAuth(ctx),
914 requirePermission(ctx, "space.atbb.permission.manageCategories"),
915 async (c) => {
916 const idParam = c.req.param("id");
917 const id = parseBigIntParam(idParam);
918 if (id === null) {
919 return c.json({ error: "Invalid board ID" }, 400);
920 }
921
922 let board: typeof boards.$inferSelect;
923 try {
924 const [row] = await ctx.db
925 .select()
926 .from(boards)
927 .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid)))
928 .limit(1);
929
930 if (!row) {
931 return c.json({ error: "Board not found" }, 404);
932 }
933 board = row;
934 } catch (error) {
935 return handleRouteError(c, error, "Failed to look up board", {
936 operation: "DELETE /api/admin/boards/:id",
937 logger: ctx.logger,
938 id: idParam,
939 });
940 }
941
942 // Pre-flight: refuse if any posts reference this board
943 try {
944 const [postCount] = await ctx.db
945 .select({ count: count() })
946 .from(posts)
947 .where(eq(posts.boardId, id));
948
949 if (postCount && postCount.count > 0) {
950 return c.json(
951 { error: "Cannot delete board with posts. Remove all posts first." },
952 409
953 );
954 }
955 } catch (error) {
956 return handleRouteError(c, error, "Failed to check board posts", {
957 operation: "DELETE /api/admin/boards/:id",
958 logger: ctx.logger,
959 id: idParam,
960 });
961 }
962
963 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/boards/:id");
964 if (agentError) return agentError;
965
966 try {
967 await agent.com.atproto.repo.deleteRecord({
968 repo: ctx.config.forumDid,
969 collection: "space.atbb.forum.board",
970 rkey: board.rkey,
971 });
972
973 return c.json({ success: true });
974 } catch (error) {
975 return handleRouteError(c, error, "Failed to delete board", {
976 operation: "DELETE /api/admin/boards/:id",
977 logger: ctx.logger,
978 id: idParam,
979 });
980 }
981 }
982 );
983
984 /**
985 * GET /api/admin/modlog
986 *
987 * Paginated, reverse-chronological list of mod actions.
988 * Joins users table twice: once for the moderator handle (via createdBy),
989 * once for the subject handle (via subjectDid, nullable for post-targeting actions).
990 *
991 * Uses leftJoin for both users joins so actions are never dropped when a
992 * moderator or subject DID has no indexed users row. moderatorHandle falls
993 * back to moderatorDid in that case.
994 *
995 * Requires any of: moderatePosts, banUsers, lockTopics.
996 */
997 app.get(
998 "/modlog",
999 requireAuth(ctx),
1000 requireAnyPermission(ctx, [
1001 "space.atbb.permission.moderatePosts",
1002 "space.atbb.permission.banUsers",
1003 "space.atbb.permission.lockTopics",
1004 ]),
1005 async (c) => {
1006 const rawLimit = c.req.query("limit");
1007 const rawOffset = c.req.query("offset");
1008
1009 if (rawLimit !== undefined && (!/^\d+$/.test(rawLimit))) {
1010 return c.json({ error: "limit must be a positive integer" }, 400);
1011 }
1012 if (rawOffset !== undefined && (!/^\d+$/.test(rawOffset))) {
1013 return c.json({ error: "offset must be a non-negative integer" }, 400);
1014 }
1015
1016 const limitVal = rawLimit !== undefined ? parseInt(rawLimit, 10) : 50;
1017 const offsetVal = rawOffset !== undefined ? parseInt(rawOffset, 10) : 0;
1018
1019 if (rawLimit !== undefined && limitVal < 1) {
1020 return c.json({ error: "limit must be a positive integer" }, 400);
1021 }
1022 if (rawOffset !== undefined && offsetVal < 0) {
1023 return c.json({ error: "offset must be a non-negative integer" }, 400);
1024 }
1025
1026 const clampedLimit = Math.min(limitVal, 100);
1027
1028 const moderatorUser = alias(users, "moderator_user");
1029 const subjectUser = alias(users, "subject_user");
1030
1031 try {
1032 const [countResult, actions] = await Promise.all([
1033 ctx.db
1034 .select({ total: count() })
1035 .from(modActions)
1036 .where(eq(modActions.did, ctx.config.forumDid)),
1037 ctx.db
1038 .select({
1039 id: modActions.id,
1040 action: modActions.action,
1041 moderatorDid: modActions.createdBy,
1042 moderatorHandle: moderatorUser.handle,
1043 subjectDid: modActions.subjectDid,
1044 subjectHandle: subjectUser.handle,
1045 subjectPostUri: modActions.subjectPostUri,
1046 reason: modActions.reason,
1047 createdAt: modActions.createdAt,
1048 })
1049 .from(modActions)
1050 .where(eq(modActions.did, ctx.config.forumDid))
1051 .leftJoin(moderatorUser, eq(modActions.createdBy, moderatorUser.did))
1052 .leftJoin(subjectUser, eq(modActions.subjectDid, subjectUser.did))
1053 .orderBy(desc(modActions.createdAt))
1054 .limit(clampedLimit)
1055 .offset(offsetVal),
1056 ]);
1057
1058 const total = Number(countResult[0]?.total ?? 0);
1059
1060 return c.json({
1061 actions: actions.map((a) => ({
1062 id: a.id.toString(),
1063 action: a.action,
1064 moderatorDid: a.moderatorDid,
1065 moderatorHandle: a.moderatorHandle ?? a.moderatorDid,
1066 subjectDid: a.subjectDid ?? null,
1067 subjectHandle: a.subjectHandle ?? null,
1068 subjectPostUri: a.subjectPostUri ?? null,
1069 reason: a.reason ?? null,
1070 createdAt: a.createdAt.toISOString(),
1071 })),
1072 total,
1073 offset: offsetVal,
1074 limit: clampedLimit,
1075 });
1076 } catch (error) {
1077 return handleRouteError(c, error, "Failed to retrieve mod action log", {
1078 operation: "GET /api/admin/modlog",
1079 logger: ctx.logger,
1080 });
1081 }
1082 }
1083 );
1084
1085 return app;
1086}