import { Hono } from "hono"; import type { AppContext } from "../lib/app-context.js"; import type { Variables } from "../types.js"; import { requireAuth } from "../middleware/auth.js"; import { requirePermission, requireAnyPermission, getUserRole } from "../middleware/permissions.js"; import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions } from "@atbb/db"; import { eq, and, sql, asc, desc, count } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { isProgrammingError } from "../lib/errors.js"; import { BackfillStatus } from "../lib/backfill-manager.js"; import { CursorManager } from "../lib/cursor-manager.js"; import { handleRouteError, safeParseJsonBody, getForumAgentOrError, } from "../lib/route-errors.js"; import { TID } from "@atproto/common-web"; import { parseBigIntParam } from "./helpers.js"; export function createAdminRoutes(ctx: AppContext) { const app = new Hono<{ Variables: Variables }>(); /** * POST /api/admin/members/:did/role * * Assign a role to a forum member. */ app.post( "/members/:did/role", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.manageRoles"), async (c) => { const targetDid = c.req.param("did"); const user = c.get("user")!; // Parse and validate request body const { body, error: parseError } = await safeParseJsonBody(c); if (parseError) return parseError; const { roleUri } = body; if (typeof roleUri !== "string") { return c.json({ error: "roleUri is required and must be a string" }, 400); } // Validate roleUri format if (!roleUri.startsWith("at://") || !roleUri.includes("/space.atbb.forum.role/")) { return c.json({ error: "Invalid roleUri format" }, 400); } // Extract role rkey from roleUri const roleRkey = roleUri.split("/").pop(); if (!roleRkey) { return c.json({ error: "Invalid roleUri format" }, 400); } try { // Validate role exists const [role] = await ctx.db .select() .from(roles) .where( and( eq(roles.did, ctx.config.forumDid), eq(roles.rkey, roleRkey) ) ) .limit(1); if (!role) { return c.json({ error: "Role not found" }, 404); } // Priority check: Can't assign role with equal or higher authority const assignerRole = await getUserRole(ctx, user.did); if (!assignerRole) { return c.json({ error: "You do not have a role assigned" }, 403); } if (role.priority <= assignerRole.priority) { return c.json({ error: "Cannot assign role with equal or higher authority", }, 403); } // Get target user's membership const [membership] = await ctx.db .select() .from(memberships) .where(eq(memberships.did, targetDid)) .limit(1); if (!membership) { return c.json({ error: "User is not a member of this forum" }, 404); } // Fetch forum CID for membership record const [forum] = await ctx.db .select({ cid: forums.cid }) .from(forums) .where(eq(forums.did, ctx.config.forumDid)) .limit(1); if (!forum) { return c.json({ error: "Forum record not found in database" }, 500); } // Get ForumAgent for PDS write operations const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/members/:did/role"); if (agentError) return agentError; try { // Update membership record on user's PDS using ForumAgent await agent.com.atproto.repo.putRecord({ repo: targetDid, collection: "space.atbb.membership", rkey: membership.rkey, record: { $type: "space.atbb.membership", forum: { forum: { uri: membership.forumUri, cid: forum.cid } }, role: { role: { uri: roleUri, cid: role.cid } }, joinedAt: membership.joinedAt?.toISOString(), createdAt: membership.createdAt.toISOString(), }, }); return c.json({ success: true, roleAssigned: role.name, targetDid, }); } catch (error) { return handleRouteError(c, error, "Failed to assign role", { operation: "POST /api/admin/members/:did/role", logger: ctx.logger, targetDid, roleUri, }); } } catch (error) { return handleRouteError(c, error, "Failed to process role assignment", { operation: "POST /api/admin/members/:did/role", logger: ctx.logger, targetDid, roleUri, }); } } ); /** * GET /api/admin/roles * * List all available roles for the forum. */ app.get( "/roles", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.manageRoles"), async (c) => { try { const rolesList = await ctx.db .select({ id: roles.id, name: roles.name, description: roles.description, priority: roles.priority, rkey: roles.rkey, did: roles.did, }) .from(roles) .where(eq(roles.did, ctx.config.forumDid)) .orderBy(asc(roles.priority)); const rolesWithPermissions = await Promise.all( rolesList.map(async (role) => { const perms = await ctx.db .select({ permission: rolePermissions.permission }) .from(rolePermissions) .where(eq(rolePermissions.roleId, role.id)); return { id: role.id.toString(), name: role.name, description: role.description, permissions: perms.map((p) => p.permission), priority: role.priority, uri: `at://${role.did}/space.atbb.forum.role/${role.rkey}`, }; }) ); return c.json({ roles: rolesWithPermissions }); } catch (error) { return handleRouteError(c, error, "Failed to retrieve roles", { operation: "GET /api/admin/roles", logger: ctx.logger, }); } } ); /** * GET /api/admin/members * * List all forum members with their assigned roles. */ app.get( "/members", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.manageMembers"), async (c) => { try { const membersList = await ctx.db .select({ did: memberships.did, handle: users.handle, role: roles.name, roleUri: memberships.roleUri, joinedAt: memberships.joinedAt, }) .from(memberships) .leftJoin(users, eq(memberships.did, users.did)) .leftJoin( roles, sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}` ) .where(eq(memberships.forumUri, `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`)) .orderBy(asc(roles.priority), asc(users.handle)) .limit(100); return c.json({ members: membersList.map(member => ({ did: member.did, handle: member.handle || member.did, role: member.role || "Guest", roleUri: member.roleUri, joinedAt: member.joinedAt?.toISOString(), })), isTruncated: membersList.length === 100, }); } catch (error) { return handleRouteError(c, error, "Failed to retrieve members", { operation: "GET /api/admin/members", logger: ctx.logger, }); } } ); /** * GET /api/admin/members/me * * Returns the calling user's own membership, role name, and permissions. * Any authenticated user may call this — no special permission required. * Returns 404 if the user has no membership record for this forum. */ app.get("/members/me", requireAuth(ctx), async (c) => { const user = c.get("user")!; try { const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; const [member] = await ctx.db .select({ did: memberships.did, handle: users.handle, roleUri: memberships.roleUri, roleName: roles.name, roleId: roles.id, }) .from(memberships) .leftJoin(users, eq(memberships.did, users.did)) .leftJoin( roles, sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}` ) .where( and( eq(memberships.did, user.did), eq(memberships.forumUri, forumUri) ) ) .limit(1); if (!member) { return c.json({ error: "Membership not found" }, 404); } let permissions: string[] = []; if (member.roleId) { const perms = await ctx.db .select({ permission: rolePermissions.permission }) .from(rolePermissions) .where(eq(rolePermissions.roleId, member.roleId)); permissions = perms.map((p) => p.permission); } return c.json({ did: member.did, handle: member.handle || user.did, role: member.roleName || "Guest", roleUri: member.roleUri, permissions, }); } catch (error) { return handleRouteError(c, error, "Failed to retrieve your membership", { operation: "GET /api/admin/members/me", logger: ctx.logger, did: user.did, }); } }); /** * POST /api/admin/backfill * * Trigger a backfill operation. Runs asynchronously. * Returns 202 Accepted immediately. * Use ?force=catch_up or ?force=full_sync to override gap detection. */ app.post( "/backfill", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.manageForum"), async (c) => { const backfillManager = ctx.backfillManager; if (!backfillManager) { return c.json({ error: "Backfill manager not available" }, 503); } if (backfillManager.getIsRunning()) { return c.json({ error: "A backfill is already in progress" }, 409); } // Determine backfill type const force = c.req.query("force"); let type: BackfillStatus; if (force === "catch_up" || force === "full_sync") { type = force === "catch_up" ? BackfillStatus.CatchUp : BackfillStatus.FullSync; } else { try { const cursor = await new CursorManager(ctx.db, ctx.logger).load(); type = await backfillManager.checkIfNeeded(cursor); } catch (error) { if (isProgrammingError(error)) throw error; ctx.logger.error("Failed to check backfill status", { event: "backfill.admin_trigger.check_failed", error: error instanceof Error ? error.message : String(error), }); return c.json({ error: "Failed to check backfill status. Please try again later." }, 500); } if (type === BackfillStatus.NotNeeded) { return c.json({ message: "No backfill needed. Use ?force=catch_up or ?force=full_sync to override.", }, 200); } } // Create progress row first so we can return the ID immediately in the 202 response let progressId: bigint; try { progressId = await backfillManager.prepareBackfillRow(type); } catch (error) { if (isProgrammingError(error)) throw error; ctx.logger.error("Failed to create backfill row", { event: "backfill.admin_trigger.create_row_failed", error: error instanceof Error ? error.message : String(error), }); return c.json({ error: "Failed to start backfill. Please try again later." }, 500); } // Fire and forget — don't await so response is immediate backfillManager.performBackfill(type, progressId).catch((err) => { ctx.logger.error("Background backfill failed", { event: "backfill.admin_trigger_failed", backfillId: progressId.toString(), error: err instanceof Error ? err.message : String(err), }); }); return c.json({ message: "Backfill started", type, status: "in_progress", id: progressId.toString(), }, 202); } ); /** * GET /api/admin/backfill/:id * * Get status and progress for a specific backfill by ID. */ app.get( "/backfill/:id", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.manageForum"), async (c) => { const id = c.req.param("id"); if (!/^\d+$/.test(id)) { return c.json({ error: "Invalid backfill ID" }, 400); } const parsedId = BigInt(id); try { const [row] = await ctx.db .select() .from(backfillProgress) .where(eq(backfillProgress.id, parsedId)) .limit(1); if (!row) { return c.json({ error: "Backfill not found" }, 404); } const [errorCount] = await ctx.db .select({ count: count() }) .from(backfillErrors) .where(eq(backfillErrors.backfillId, row.id)); return c.json({ id: row.id.toString(), status: row.status, type: row.backfillType, didsTotal: row.didsTotal, didsProcessed: row.didsProcessed, recordsIndexed: row.recordsIndexed, errorCount: errorCount?.count ?? 0, startedAt: row.startedAt.toISOString(), completedAt: row.completedAt?.toISOString() ?? null, errorMessage: row.errorMessage, }); } catch (error) { return handleRouteError(c, error, "Failed to fetch backfill progress", { operation: "GET /api/admin/backfill/:id", logger: ctx.logger, id, }); } } ); /** * GET /api/admin/backfill/:id/errors * * List per-DID errors for a specific backfill. */ app.get( "/backfill/:id/errors", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.manageForum"), async (c) => { const id = c.req.param("id"); if (!/^\d+$/.test(id)) { return c.json({ error: "Invalid backfill ID" }, 400); } const parsedId = BigInt(id); try { const errors = await ctx.db .select() .from(backfillErrors) .where(eq(backfillErrors.backfillId, parsedId)) .orderBy(asc(backfillErrors.createdAt)) .limit(1000); return c.json({ errors: errors.map((e) => ({ id: e.id.toString(), did: e.did, collection: e.collection, errorMessage: e.errorMessage, createdAt: e.createdAt.toISOString(), })), }); } catch (error) { return handleRouteError(c, error, "Failed to fetch backfill errors", { operation: "GET /api/admin/backfill/:id/errors", logger: ctx.logger, id, }); } } ); /** * POST /api/admin/categories * * Create a new forum category. Writes space.atbb.forum.category to Forum DID's PDS. * The firehose indexer creates the DB row asynchronously. */ app.post( "/categories", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.manageCategories"), async (c) => { const { body, error: parseError } = await safeParseJsonBody(c); if (parseError) return parseError; const { name, description, sortOrder } = body; if (typeof name !== "string" || name.trim().length === 0) { return c.json({ error: "name is required and must be a non-empty string" }, 400); } const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/categories"); if (agentError) return agentError; const rkey = TID.nextStr(); const now = new Date().toISOString(); try { const result = await agent.com.atproto.repo.putRecord({ repo: ctx.config.forumDid, collection: "space.atbb.forum.category", rkey, record: { $type: "space.atbb.forum.category", name: name.trim(), ...(typeof description === "string" && { description: description.trim() }), ...(Number.isInteger(sortOrder) && sortOrder >= 0 && { sortOrder }), createdAt: now, }, }); return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); } catch (error) { return handleRouteError(c, error, "Failed to create category", { operation: "POST /api/admin/categories", logger: ctx.logger, }); } } ); /** * PUT /api/admin/categories/:id * * Update an existing category. Fetches existing rkey from DB, calls putRecord * with updated fields preserving the original createdAt. * The firehose indexer updates the DB row asynchronously. */ app.put( "/categories/:id", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.manageCategories"), async (c) => { const idParam = c.req.param("id"); const id = parseBigIntParam(idParam); if (id === null) { return c.json({ error: "Invalid category ID" }, 400); } const { body, error: parseError } = await safeParseJsonBody(c); if (parseError) return parseError; const { name, description, sortOrder } = body; if (typeof name !== "string" || name.trim().length === 0) { return c.json({ error: "name is required and must be a non-empty string" }, 400); } let category: typeof categories.$inferSelect; try { const [row] = await ctx.db .select() .from(categories) .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid))) .limit(1); if (!row) { return c.json({ error: "Category not found" }, 404); } category = row; } catch (error) { return handleRouteError(c, error, "Failed to look up category", { operation: "PUT /api/admin/categories/:id", logger: ctx.logger, id: idParam, }); } const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/categories/:id"); if (agentError) return agentError; // 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() : category.description; const resolvedSortOrder = (Number.isInteger(sortOrder) && sortOrder >= 0) ? sortOrder : category.sortOrder; try { const result = await agent.com.atproto.repo.putRecord({ repo: ctx.config.forumDid, collection: "space.atbb.forum.category", rkey: category.rkey, record: { $type: "space.atbb.forum.category", name: name.trim(), ...(resolvedDescription != null && { description: resolvedDescription }), ...(resolvedSortOrder != null && { sortOrder: resolvedSortOrder }), createdAt: category.createdAt.toISOString(), }, }); return c.json({ uri: result.data.uri, cid: result.data.cid }); } catch (error) { return handleRouteError(c, error, "Failed to update category", { operation: "PUT /api/admin/categories/:id", logger: ctx.logger, id: idParam, }); } } ); /** * DELETE /api/admin/categories/:id * * Delete a category. Pre-flight: refuses with 409 if any boards reference this * category in the DB. If clear, calls deleteRecord on the Forum DID's PDS. * The firehose indexer removes the DB row asynchronously. */ app.delete( "/categories/:id", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.manageCategories"), async (c) => { const idParam = c.req.param("id"); const id = parseBigIntParam(idParam); if (id === null) { return c.json({ error: "Invalid category ID" }, 400); } let category: typeof categories.$inferSelect; try { const [row] = await ctx.db .select() .from(categories) .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid))) .limit(1); if (!row) { return c.json({ error: "Category not found" }, 404); } category = row; } catch (error) { return handleRouteError(c, error, "Failed to look up category", { operation: "DELETE /api/admin/categories/:id", logger: ctx.logger, id: idParam, }); } // Pre-flight: refuse if any boards reference this category try { const [boardCount] = await ctx.db .select({ count: count() }) .from(boards) .where(eq(boards.categoryId, id)); if (boardCount && boardCount.count > 0) { return c.json( { error: "Cannot delete category with boards. Remove all boards first." }, 409 ); } } catch (error) { return handleRouteError(c, error, "Failed to check category boards", { operation: "DELETE /api/admin/categories/:id", logger: ctx.logger, id: idParam, }); } const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/categories/:id"); if (agentError) return agentError; try { await agent.com.atproto.repo.deleteRecord({ repo: ctx.config.forumDid, collection: "space.atbb.forum.category", rkey: category.rkey, }); return c.json({ success: true }); } catch (error) { return handleRouteError(c, error, "Failed to delete category", { operation: "DELETE /api/admin/categories/:id", logger: ctx.logger, id: idParam, }); } } ); /** * 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, }); } } ); /** * PUT /api/admin/boards/:id * * Update an existing board's name, description, and sortOrder. * Fetches existing rkey + categoryUri from DB, then fetches category CID, * then putRecord with updated fields preserving the original categoryRef and createdAt. * Category cannot be changed on edit (no reparenting). * 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 rebuild the categoryRef strongRef. // Always fetch fresh — the category's CID can change after category edits. 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, }); } } ); /** * 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, }); } } ); /** * GET /api/admin/modlog * * Paginated, reverse-chronological list of mod actions. * Joins users table twice: once for the moderator handle (via createdBy), * once for the subject handle (via subjectDid, nullable for post-targeting actions). * * Uses leftJoin for both users joins so actions are never dropped when a * moderator or subject DID has no indexed users row. moderatorHandle falls * back to moderatorDid in that case. * * Requires any of: moderatePosts, banUsers, lockTopics. */ app.get( "/modlog", requireAuth(ctx), requireAnyPermission(ctx, [ "space.atbb.permission.moderatePosts", "space.atbb.permission.banUsers", "space.atbb.permission.lockTopics", ]), async (c) => { const rawLimit = c.req.query("limit"); const rawOffset = c.req.query("offset"); if (rawLimit !== undefined && (!/^\d+$/.test(rawLimit))) { return c.json({ error: "limit must be a positive integer" }, 400); } if (rawOffset !== undefined && (!/^\d+$/.test(rawOffset))) { return c.json({ error: "offset must be a non-negative integer" }, 400); } const limitVal = rawLimit !== undefined ? parseInt(rawLimit, 10) : 50; const offsetVal = rawOffset !== undefined ? parseInt(rawOffset, 10) : 0; if (rawLimit !== undefined && limitVal < 1) { return c.json({ error: "limit must be a positive integer" }, 400); } if (rawOffset !== undefined && offsetVal < 0) { return c.json({ error: "offset must be a non-negative integer" }, 400); } const clampedLimit = Math.min(limitVal, 100); const moderatorUser = alias(users, "moderator_user"); const subjectUser = alias(users, "subject_user"); try { const [countResult, actions] = await Promise.all([ ctx.db .select({ total: count() }) .from(modActions) .where(eq(modActions.did, ctx.config.forumDid)), ctx.db .select({ id: modActions.id, action: modActions.action, moderatorDid: modActions.createdBy, moderatorHandle: moderatorUser.handle, subjectDid: modActions.subjectDid, subjectHandle: subjectUser.handle, subjectPostUri: modActions.subjectPostUri, reason: modActions.reason, createdAt: modActions.createdAt, }) .from(modActions) .where(eq(modActions.did, ctx.config.forumDid)) .leftJoin(moderatorUser, eq(modActions.createdBy, moderatorUser.did)) .leftJoin(subjectUser, eq(modActions.subjectDid, subjectUser.did)) .orderBy(desc(modActions.createdAt)) .limit(clampedLimit) .offset(offsetVal), ]); const total = Number(countResult[0]?.total ?? 0); return c.json({ actions: actions.map((a) => ({ id: a.id.toString(), action: a.action, moderatorDid: a.moderatorDid, moderatorHandle: a.moderatorHandle ?? a.moderatorDid, subjectDid: a.subjectDid ?? null, subjectHandle: a.subjectHandle ?? null, subjectPostUri: a.subjectPostUri ?? null, reason: a.reason ?? null, createdAt: a.createdAt.toISOString(), })), total, offset: offsetVal, limit: clampedLimit, }); } catch (error) { return handleRouteError(c, error, "Failed to retrieve mod action log", { operation: "GET /api/admin/modlog", logger: ctx.logger, }); } } ); return app; }