WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

feat(appview): GET /api/admin/modlog with double users leftJoin (ATB-46)

+101 -3
+101 -3
apps/appview/src/routes/admin.ts
··· 2 2 import type { AppContext } from "../lib/app-context.js"; 3 3 import type { Variables } from "../types.js"; 4 4 import { requireAuth } from "../middleware/auth.js"; 5 - import { requirePermission, getUserRole } from "../middleware/permissions.js"; 6 - import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts } from "@atbb/db"; 7 - import { eq, and, sql, asc, count } from "drizzle-orm"; 5 + import { requirePermission, requireAnyPermission, getUserRole } from "../middleware/permissions.js"; 6 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions } from "@atbb/db"; 7 + import { eq, and, sql, asc, desc, count } from "drizzle-orm"; 8 + import { alias } from "drizzle-orm/pg-core"; 8 9 import { isProgrammingError } from "../lib/errors.js"; 9 10 import { BackfillStatus } from "../lib/backfill-manager.js"; 10 11 import { CursorManager } from "../lib/cursor-manager.js"; ··· 975 976 operation: "DELETE /api/admin/boards/:id", 976 977 logger: ctx.logger, 977 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.select({ total: count() }).from(modActions), 1034 + ctx.db 1035 + .select({ 1036 + id: modActions.id, 1037 + action: modActions.action, 1038 + moderatorDid: modActions.createdBy, 1039 + moderatorHandle: moderatorUser.handle, 1040 + subjectDid: modActions.subjectDid, 1041 + subjectHandle: subjectUser.handle, 1042 + subjectPostUri: modActions.subjectPostUri, 1043 + reason: modActions.reason, 1044 + createdAt: modActions.createdAt, 1045 + }) 1046 + .from(modActions) 1047 + .leftJoin(moderatorUser, eq(modActions.createdBy, moderatorUser.did)) 1048 + .leftJoin(subjectUser, eq(modActions.subjectDid, subjectUser.did)) 1049 + .orderBy(desc(modActions.createdAt)) 1050 + .limit(clampedLimit) 1051 + .offset(offsetVal), 1052 + ]); 1053 + 1054 + const total = Number(countResult[0]?.total ?? 0); 1055 + 1056 + return c.json({ 1057 + actions: actions.map((a) => ({ 1058 + id: a.id.toString(), 1059 + action: a.action, 1060 + moderatorDid: a.moderatorDid, 1061 + moderatorHandle: a.moderatorHandle ?? a.moderatorDid, 1062 + subjectDid: a.subjectDid ?? null, 1063 + subjectHandle: a.subjectHandle ?? null, 1064 + subjectPostUri: a.subjectPostUri ?? null, 1065 + reason: a.reason ?? null, 1066 + createdAt: a.createdAt.toISOString(), 1067 + })), 1068 + total, 1069 + offset: offsetVal, 1070 + limit: clampedLimit, 1071 + }); 1072 + } catch (error) { 1073 + return handleRouteError(c, error, "Failed to retrieve mod action log", { 1074 + operation: "GET /api/admin/modlog", 1075 + logger: ctx.logger, 978 1076 }); 979 1077 } 980 1078 }