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
at atb-52-css-token-extraction 1086 lines 36 kB view raw
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}