From a069aca6cb35f63a2c7bffe64655a283b09c2a13 Mon Sep 17 00:00:00 2001 From: aqua427 <216757359+acarlson33@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:27:19 -0500 Subject: [PATCH] Moderation Page overhaul --- apps/web/CHANGELOG.md | 1 + .../src/__tests__/moderation-actions.test.ts | 151 ++- .../app/moderation/ModerationMessageList.tsx | 13 +- apps/web/src/app/moderation/actions.ts | 116 +- apps/web/src/app/moderation/page.tsx | 991 ++++++------------ .../web/src/components/moderation-sidebar.tsx | 172 +++ .../web/src/components/server-admin-panel.tsx | 75 +- 7 files changed, 740 insertions(+), 779 deletions(-) create mode 100644 apps/web/src/components/moderation-sidebar.tsx diff --git a/apps/web/CHANGELOG.md b/apps/web/CHANGELOG.md index 3feab8d..d7e4420 100644 --- a/apps/web/CHANGELOG.md +++ b/apps/web/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Signup control** - Admins can set the instance policy to open, individual approval, or no signups, and approve/reject pending signups from the admin panel - **Deactivate & delete account** - Temporarily deactivate your account (auto-reactivates on next sign-in) or permanently delete it from a new Danger Zone section - **Deleted User tombstones** - Deleted accounts show as "Deleted User" and their user ID is permanently reserved so it can never be reused +- **Server-scoped moderation workspace** - The Moderation panel now works per server: pick a server and channel from the sidebar to review its messages. Anyone with the Manage Messages permission (or a global moderator) can soft-delete/restore, and server owners/admins (or global admins) can permanently delete. The redundant Moderation tab inside the server admin panel was replaced with a link into this workspace ### ⚙️ Improvements diff --git a/apps/web/src/__tests__/moderation-actions.test.ts b/apps/web/src/__tests__/moderation-actions.test.ts index 9d41476..b864e3f 100644 --- a/apps/web/src/__tests__/moderation-actions.test.ts +++ b/apps/web/src/__tests__/moderation-actions.test.ts @@ -12,7 +12,6 @@ env.APPWRITE_ENDPOINT = "http://localhost"; env.APPWRITE_PROJECT_ID = "test-project"; env.APPWRITE_API_KEY = "test-api-key"; -vi.mock("../lib/appwrite-roles", () => ({ getUserRoles: vi.fn() })); vi.mock("../lib/appwrite-audit", () => ({ recordAudit: vi.fn() })); vi.mock("../lib/appwrite-admin", () => ({ adminSoftDeleteMessage: vi.fn(), @@ -20,13 +19,31 @@ vi.mock("../lib/appwrite-admin", () => ({ adminDeleteMessage: vi.fn(), getAdminMessageAuditContext: vi.fn(), })); +vi.mock("../lib/server-channel-access", () => ({ + getServerPermissionsForUser: vi.fn(), +})); +vi.mock("../lib/appwrite-core", () => ({ + getEnvConfig: vi.fn().mockReturnValue({ + project: "test-project", + databaseId: "db", + collections: { + servers: "servers", + channels: "channels", + messages: "messages", + }, + }), +})); +vi.mock("../lib/appwrite-server", () => ({ + getServerClient: vi.fn().mockReturnValue({ databases: {}, client: {} }), +})); vi.mock("next/headers", () => ({ cookies: async () => ({ get: () => ({ value: "session" }) }), })); // Mock auth-server helper vi.mock("../lib/auth-server", () => ({ - requireModerator: vi.fn(), + requireAuth: vi.fn(), + checkUserRoles: vi.fn(), })); // Mock Appwrite SDK for getServerSession @@ -54,7 +71,6 @@ vi.mock("appwrite", () => { return mod; }); -const { getUserRoles } = await import("../lib/appwrite-roles"); const { adminSoftDeleteMessage, adminRestoreMessage, @@ -62,22 +78,45 @@ const { getAdminMessageAuditContext, } = await import("../lib/appwrite-admin"); const { recordAudit } = await import("../lib/appwrite-audit"); -const { requireModerator } = await import("../lib/auth-server"); +const { getServerPermissionsForUser } = await import( + "../lib/server-channel-access" +); +const { requireAuth, checkUserRoles } = await import("../lib/auth-server"); -function setRole(mod: boolean, admin: boolean) { - (getUserRoles as any).mockResolvedValue({ +function setGlobalRoles(mod: boolean, admin: boolean) { + (requireAuth as any).mockResolvedValue({ + $id: "moderatorUser", + name: "Mod", + email: "mod@example.com", + }); + (checkUserRoles as any).mockResolvedValue({ isModerator: mod, isAdmin: admin, }); - (requireModerator as any).mockResolvedValue({ - user: { $id: "moderatorUser", name: "Mod", email: "mod@example.com" }, - roles: { isModerator: mod, isAdmin: admin }, +} + +function setServerAccess(access: { + isServerOwner?: boolean; + manageMessages?: boolean; + administrator?: boolean; +}) { + (getServerPermissionsForUser as any).mockResolvedValue({ + serverId: "server-1", + isServerOwner: access.isServerOwner ?? false, + isMember: true, + permissions: { + manageMessages: access.manageMessages ?? false, + administrator: access.administrator ?? false, + }, + roleIds: [], + roles: [], }); } beforeEach(async () => { vi.clearAllMocks(); - setRole(true, true); + setGlobalRoles(true, true); + setServerAccess({ manageMessages: true }); (getAdminMessageAuditContext as any).mockResolvedValue({ $id: "m1", userId: "author-1", @@ -88,7 +127,7 @@ beforeEach(async () => { }); describe("moderation actions", () => { - it("soft delete records audit + metrics", async () => { + it("soft delete records audit + metrics for global admin", async () => { await actionSoftDelete("m1"); expect(adminSoftDeleteMessage).toHaveBeenCalledWith( "m1", @@ -105,12 +144,47 @@ describe("moderation actions", () => { }), ); }); + + it("soft delete allowed for server moderator with manageMessages", async () => { + setGlobalRoles(false, false); + setServerAccess({ manageMessages: true }); + await actionSoftDelete("m2"); + expect(adminSoftDeleteMessage).toHaveBeenCalledWith( + "m2", + "moderatorUser", + ); + }); + + it("soft delete allowed for server owner", async () => { + setGlobalRoles(false, false); + setServerAccess({ isServerOwner: true }); + await actionSoftDelete("m3"); + expect(adminSoftDeleteMessage).toHaveBeenCalledWith( + "m3", + "moderatorUser", + ); + }); + + it("soft delete forbidden without manageMessages or global role", async () => { + setGlobalRoles(false, false); + setServerAccess({}); + await expect(actionSoftDelete("m4")).rejects.toThrow("Forbidden"); + expect(adminSoftDeleteMessage).not.toHaveBeenCalled(); + }); + + it("soft delete allowed for global moderator even without server perms", async () => { + setGlobalRoles(true, false); + setServerAccess({}); + await actionSoftDelete("m5"); + expect(adminSoftDeleteMessage).toHaveBeenCalled(); + }); + it("restore records audit", async () => { - await actionRestore("m2"); - expect(adminRestoreMessage).toHaveBeenCalledWith("m2"); + await actionRestore("m6"); + expect(adminRestoreMessage).toHaveBeenCalledWith("m6"); expect(recordAudit).toHaveBeenCalledWith( "restore", - "m2", + "m6", "moderatorUser", expect.objectContaining({ serverId: "server-1", @@ -118,13 +192,13 @@ describe("moderation actions", () => { }), ); }); - it("hard delete requires admin", async () => { - setRole(true, true); - await actionHardDelete("m3"); - expect(adminDeleteMessage).toHaveBeenCalledWith("m3"); + + it("hard delete allowed for global admin", async () => { + await actionHardDelete("m7"); + expect(adminDeleteMessage).toHaveBeenCalledWith("m7"); expect(recordAudit).toHaveBeenCalledWith( "hard_delete", - "m3", + "m7", "moderatorUser", expect.objectContaining({ serverId: "server-1", @@ -135,8 +209,39 @@ describe("moderation actions", () => { }), ); }); - it("hard delete forbidden for non-admin", async () => { - setRole(true, false); - await expect(actionHardDelete("m4")).rejects.toThrow("Forbidden"); + + it("hard delete allowed for server administrator role", async () => { + setGlobalRoles(false, false); + setServerAccess({ administrator: true, manageMessages: true }); + await actionHardDelete("m8"); + expect(adminDeleteMessage).toHaveBeenCalledWith("m8"); }); -}); + + it("hard delete allowed for server owner", async () => { + setGlobalRoles(false, false); + setServerAccess({ isServerOwner: true }); + await actionHardDelete("m9"); + expect(adminDeleteMessage).toHaveBeenCalledWith("m9"); + }); + + it("hard delete forbidden for non-admin server moderator", async () => { + setGlobalRoles(false, false); + setServerAccess({ manageMessages: true }); + await expect(actionHardDelete("m10")).rejects.toThrow("Forbidden"); + expect(adminDeleteMessage).not.toHaveBeenCalled(); + }); + + it("hard delete forbidden for global moderator without admin", async () => { + setGlobalRoles(true, false); + setServerAccess({ isServerOwner: false, administrator: false }); + await expect(actionHardDelete("m11")).rejects.toThrow("Forbidden"); + expect(adminDeleteMessage).not.toHaveBeenCalled(); + }); + + it("throws if the message does not exist", async () => { + (getAdminMessageAuditContext as any).mockResolvedValue(null); + await expect(actionSoftDelete("m12")).rejects.toThrow( + "Message not found", + ); + }); +}); \ No newline at end of file diff --git a/apps/web/src/app/moderation/ModerationMessageList.tsx b/apps/web/src/app/moderation/ModerationMessageList.tsx index 852aa94..cf43e35 100644 --- a/apps/web/src/app/moderation/ModerationMessageList.tsx +++ b/apps/web/src/app/moderation/ModerationMessageList.tsx @@ -37,6 +37,7 @@ type Props = { initialMessages: ModerationMessage[]; badgeMap: Record; isAdmin: boolean; + channelId?: string; }; function isOptionalString(value: unknown): value is string | undefined { @@ -172,6 +173,7 @@ export function ModerationMessageList({ initialMessages, badgeMap, isAdmin, + channelId, }: Props) { const [messages, setMessages] = useState(initialMessages); const router = useRouter(); @@ -247,11 +249,16 @@ export function ModerationMessageList({ prev.filter((m) => m.$id !== payload.$id), ); } else if (hasCreateEvent) { - // Add new message at the top if it does not already exist + // Add new message at the top if it matches the + // scoped channel and does not already exist setMessages((prev) => - prev.some((m) => m.$id === payload.$id) + channelId && + payload.channelId && + payload.channelId !== channelId ? prev - : [payload, ...prev], + : prev.some((m) => m.$id === payload.$id) + ? prev + : [payload, ...prev], ); } }, diff --git a/apps/web/src/app/moderation/actions.ts b/apps/web/src/app/moderation/actions.ts index 50b22b2..3ee3562 100644 --- a/apps/web/src/app/moderation/actions.ts +++ b/apps/web/src/app/moderation/actions.ts @@ -8,7 +8,10 @@ import { adminRestoreMessage, adminSoftDeleteMessage, } from "../../lib/appwrite-admin"; -import { requireModerator } from "../../lib/auth-server"; +import { getEnvConfig } from "../../lib/appwrite-core"; +import { getServerClient } from "../../lib/appwrite-server"; +import { checkUserRoles, requireAuth } from "../../lib/auth-server"; +import { getServerPermissionsForUser } from "../../lib/server-channel-access"; // Simple in-memory rate limiting (best effort, per runtime instance) const ACTION_WINDOW_MS = 5000; @@ -35,9 +38,83 @@ function checkRate(userId: string, action: string, messageId: string) { lastActionKey[key] = now; } -async function assertModerator() { - const { user } = await requireModerator(); - return { userId: user.$id }; +type ResolvedGate = { + user: { $id: string; name: string; email: string }; + roles: Awaited>; + message: NonNullable< + Awaited> + >; +}; + +async function resolveMessageGate(messageId: string): Promise { + const user = await requireAuth(); + const roles = await checkUserRoles(user.$id); + const message = await getAdminMessageAuditContext(messageId); + if (!message) { + throw new Error("Message not found"); + } + return { user, roles, message }; +} + +function canSoftDelete( + roles: Awaited>, + message: NonNullable< + Awaited> + >, + access: Awaited< + ReturnType + > | null, +) { + if (roles.isModerator || roles.isAdmin) { + return true; + } + if (!message.serverId) { + return false; + } + return ( + access?.isServerOwner === true || + access?.permissions.manageMessages === true + ); +} + +function canHardDelete( + roles: Awaited>, + message: NonNullable< + Awaited> + >, + access: Awaited< + ReturnType + > | null, +) { + if (roles.isAdmin) { + return true; + } + if (!message.serverId) { + return false; + } + return ( + access?.isServerOwner === true || + access?.permissions.administrator === true + ); +} + +async function resolveServerAccess( + userId: string, + message: NonNullable< + Awaited> + >, +): Promise> | null> { + if (!message.serverId) { + return null; + } + const env = getEnvConfig(); + const { databases } = getServerClient(); + return getServerPermissionsForUser( + databases, + env, + message.serverId, + userId, + ); } function trimMessagePreview(text?: string) { @@ -83,9 +160,16 @@ function buildMessageAuditMeta( } export async function actionSoftDelete(messageId: string) { - const { userId } = await assertModerator(); + const gate = await resolveMessageGate(messageId); + const access = await resolveServerAccess(gate.user.$id, gate.message); + if (!canSoftDelete(gate.roles, gate.message, access)) { + throw new Error( + "Forbidden: You need the manage messages permission in this server", + ); + } + const userId = gate.user.$id; checkRate(userId, "soft_delete", messageId); - const message = await getAdminMessageAuditContext(messageId); + const message = gate.message; await adminSoftDeleteMessage(messageId, userId); await recordAudit( "soft_delete", @@ -98,9 +182,16 @@ export async function actionSoftDelete(messageId: string) { } export async function actionRestore(messageId: string) { - const { userId } = await assertModerator(); + const gate = await resolveMessageGate(messageId); + const access = await resolveServerAccess(gate.user.$id, gate.message); + if (!canSoftDelete(gate.roles, gate.message, access)) { + throw new Error( + "Forbidden: You need the manage messages permission in this server", + ); + } + const userId = gate.user.$id; checkRate(userId, "restore", messageId); - const message = await getAdminMessageAuditContext(messageId); + const message = gate.message; await adminRestoreMessage(messageId); await recordAudit( "restore", @@ -113,15 +204,16 @@ export async function actionRestore(messageId: string) { } export async function actionHardDelete(messageId: string) { - const { user, roles } = await requireModerator(); - if (!roles.isAdmin) { + const gate = await resolveMessageGate(messageId); + const access = await resolveServerAccess(gate.user.$id, gate.message); + if (!canHardDelete(gate.roles, gate.message, access)) { throw new Error( "Forbidden: Only admins can permanently delete messages", ); } - const userId = user.$id; + const userId = gate.user.$id; checkRate(userId, "hard_delete", messageId); - const message = await getAdminMessageAuditContext(messageId); + const message = gate.message; await adminDeleteMessage(messageId); await recordAudit( "hard_delete", diff --git a/apps/web/src/app/moderation/page.tsx b/apps/web/src/app/moderation/page.tsx index 93678af..5071485 100644 --- a/apps/web/src/app/moderation/page.tsx +++ b/apps/web/src/app/moderation/page.tsx @@ -1,147 +1,178 @@ +import { Query } from "node-appwrite"; import { redirect } from "next/navigation"; +import { MessageSquare, ShieldAlert } from "lucide-react"; +import type { FileAttachment, Server } from "@/lib/types"; import { - Filter, - Hash, - MessageSquare, - SearchCheck, - Server, - ShieldAlert, -} from "lucide-react"; -import type { ReactNode } from "react"; -import type { FileAttachment } from "@/lib/types"; -import { - getBasicStats, - listAllChannelsPage, + getAdminClient, listAllServersPage, listGlobalMessages, } from "@/lib/appwrite-admin"; +import { getEnvConfig } from "@/lib/appwrite-core"; +import { listMembershipsForUser } from "@/lib/appwrite-servers"; import { getProfilesByUserIds } from "@/lib/appwrite-profiles"; import { getUserRoleTags } from "@/lib/appwrite-roles"; -import { requireModerator } from "@/lib/auth-server"; +import { checkUserRoles, requireAuth } from "@/lib/auth-server"; +import { getServerClient } from "@/lib/appwrite-server"; +import { + getChannelAccessForUser, + getServerPermissionsForUser, +} from "@/lib/server-channel-access"; +import { chunkValues } from "@/lib/appwrite-pagination"; +import { ModerationSidebar } from "@/components/moderation-sidebar"; import { ModerationMessageList } from "./ModerationMessageList"; -// server component file; no client side hooks required +// Data is per-user and per-server; don't pre-render statically. +export const instant = false; + +const SERVER_PAGE_SCAN_LIMIT = 3; // safety cap on pages +const MESSAGE_LIMIT = 30; -type ModerationSearchParams = { - limit: number; - includeRemoved: boolean; - onlyRemoved: boolean; - userFilter?: string; - channelFilter?: string; - channelIdsFilter?: string[]; - serverFilter?: string; - onlyMissingServerId?: boolean; // Added to the type definition - textFilter?: string; - cursor?: string; +type ModerationMessage = { + $id: string; + attachments?: FileAttachment[]; + imageUrl?: string; + removedAt?: string; + removedBy?: string; + serverId?: string; + channelId?: string; + text?: string; + userId?: string; + userName?: string; + mentions?: string[]; }; -function parseModerationParams( - searchParams?: Record, -): ModerationSearchParams { - const defaultModerationLimit = 30; - const limit = Number(searchParams?.limit) || defaultModerationLimit; - return { - limit, - includeRemoved: searchParams?.includeRemoved === "true", - onlyRemoved: searchParams?.onlyRemoved === "true", - userFilter: - typeof searchParams?.userId === "string" - ? searchParams?.userId - : undefined, - channelFilter: - typeof searchParams?.channelId === "string" - ? searchParams?.channelId - : undefined, - channelIdsFilter: (() => { - const raw = searchParams?.channelIds; - if (!raw) { - return; - } - if (Array.isArray(raw)) { - return raw.filter( - (v): v is string => - typeof v === "string" && v.trim().length > 0, - ); - } - if (typeof raw === "string") { - return raw - .split(",") - .map((p) => p.trim()) - .filter((p) => p.length > 0); - } - })(), - serverFilter: - typeof searchParams?.serverId === "string" - ? searchParams?.serverId - : undefined, - onlyMissingServerId: searchParams?.onlyMissingServerId === "true", - textFilter: - typeof searchParams?.q === "string" ? searchParams?.q : undefined, - cursor: - typeof searchParams?.cursor === "string" - ? searchParams?.cursor - : undefined, - }; +type ModerationDisplayMessage = ModerationMessage & { + senderDisplay: string; + serverDisplay: string; + channelDisplay: string; + removedByDisplay?: string; +}; + +function shortId(value?: string) { + if (!value) { + return ""; + } + return value.slice(0, 8); } -const CHANNEL_PAGE_SCAN_LIMIT = 3; // safety cap on pages -const SERVER_PAGE_SCAN_LIMIT = 3; // safety cap on pages +async function fetchServerOptions(serverIds: string[]): Promise { + const env = getEnvConfig(); + const { databases } = getAdminClient(); + const pages = await Promise.all( + chunkValues(serverIds, 100).map((chunk) => + databases.listDocuments(env.databaseId, env.collections.servers, [ + Query.equal("$id", chunk), + Query.limit(chunk.length), + ]), + ), + ); + const servers: Server[] = []; + for (const page of pages) { + for (const raw of page.documents) { + const d = raw as Record; + servers.push({ + $id: String(d.$id), + name: typeof d.name === "string" && d.name ? d.name : "Unnamed Server", + $createdAt: String(d.$createdAt ?? ""), + ownerId: String(d.ownerId ?? ""), + }); + } + } + return servers; +} -async function getAllServers() { - const collected: { $id: string; name: string }[] = []; +async function listAllServerIds(): Promise { + const ids: string[] = []; let cursor: string | undefined; - const pageLimit = 100; for (let i = 0; i < SERVER_PAGE_SCAN_LIMIT; i += 1) { - const page = await listAllServersPage(pageLimit, cursor); - collected.push( - ...page.items.map((s) => ({ - $id: s.$id, - name: s.name || "Unnamed Server", - })), - ); + const page = await listAllServersPage(100, cursor); + ids.push(...page.items.map((s) => s.$id)); if (!page.nextCursor) { break; } cursor = page.nextCursor; } - return collected; + return ids; } -async function getServerChannels(serverId: string | undefined) { - if (!serverId) { - return [] as { $id: string; name: string }[]; +async function listModeratableServers( + userId: string, + roles: Awaited>, +): Promise { + let serverIds: string[]; + if (roles.isModerator || roles.isAdmin) { + serverIds = await listAllServerIds(); + } else { + const memberships = await listMembershipsForUser(userId); + serverIds = [...new Set(memberships.map((m) => m.serverId))]; } - const collected: { $id: string; name: string }[] = []; - let cursor: string | undefined; - const pageLimit = 100; - for (let i = 0; i < CHANNEL_PAGE_SCAN_LIMIT; i += 1) { - const page = await listAllChannelsPage(serverId, pageLimit, cursor); - collected.push( - ...page.items.map((c) => ({ $id: c.$id, name: c.name || "" })), - ); - if (!page.nextCursor) { - break; + + const servers = await fetchServerOptions(serverIds); + if (roles.isModerator || roles.isAdmin) { + return servers; + } + + const env = getEnvConfig(); + const { databases } = getServerClient(); + const moderated: Server[] = []; + for (const server of servers) { + if (server.ownerId === userId) { + moderated.push(server); + continue; + } + try { + const access = await getServerPermissionsForUser( + databases, + env, + server.$id, + userId, + ); + if (access.permissions.manageMessages) { + moderated.push(server); + } + } catch { + // Skip servers the user can no longer access (e.g. deleted) } - cursor = page.nextCursor; } - return collected; + return moderated; } -function buildFetchInput(params: ModerationSearchParams) { - return { - limit: params.limit, - cursorAfter: params.cursor, - includeRemoved: params.includeRemoved, - onlyRemoved: params.onlyRemoved, - userId: params.userFilter, - channelId: params.channelIdsFilter?.length - ? undefined - : params.channelFilter, - channelIds: params.channelIdsFilter, - serverId: params.serverFilter, - onlyMissingServerId: params.onlyMissingServerId, - text: params.textFilter, - }; +async function resolveSelectedChannel( + serverId: string, + userId: string, + channelId: string | undefined, +) { + if (!channelId) { + return null; + } + const env = getEnvConfig(); + const { databases } = getServerClient(); + try { + const doc = await databases.getDocument( + env.databaseId, + env.collections.channels, + channelId, + ); + const d = doc as Record; + if (String(d.serverId) !== serverId) { + return null; + } + const access = await getChannelAccessForUser( + databases, + env, + channelId, + userId, + ); + if (!access.canRead) { + return null; + } + return { + $id: channelId, + name: typeof d.name === "string" && d.name ? d.name : "channel", + }; + } catch { + return null; + } } async function buildBadgeMapSimple( @@ -164,64 +195,13 @@ async function buildBadgeMapSimple( return map; } -type ModerationMessage = { - $id: string; - attachments?: FileAttachment[]; - imageUrl?: string; - removedAt?: string; - removedBy?: string; - serverId?: string; - channelId?: string; - text?: string; - userId?: string; - userName?: string; - mentions?: string[]; -}; - -type ModerationDisplayMessage = ModerationMessage & { - senderDisplay: string; - serverDisplay: string; - channelDisplay: string; - removedByDisplay?: string; -}; - -function shortId(value?: string) { - if (!value) { - return ""; - } - return value.slice(0, 8); -} - -async function buildChannelNameMap(serverIds: string[]) { - const channelNameMap = new Map(); - for (const serverId of serverIds) { - let cursor: string | undefined; - for (let i = 0; i < CHANNEL_PAGE_SCAN_LIMIT; i += 1) { - const page = await listAllChannelsPage(serverId, 100, cursor); - for (const channel of page.items) { - channelNameMap.set(channel.$id, channel.name || ""); - } - if (!page.nextCursor) { - break; - } - cursor = page.nextCursor; - } - } - return channelNameMap; -} - -async function enrichModerationMessages( +async function enrichForChannel( documents: ModerationMessage[], - servers: { $id: string; name: string }[], -) { - const serverNameMap = new Map(servers.map((s) => [s.$id, s.name])); - const serverIds = new Set(); + serverName: string, + channelName: string, +): Promise { const userIds = new Set(); - for (const message of documents) { - if (message.serverId) { - serverIds.add(message.serverId); - } if (message.userId) { userIds.add(message.userId); } @@ -229,44 +209,27 @@ async function enrichModerationMessages( userIds.add(message.removedBy); } } - - const [channelNameMap, profilesByUserId] = await Promise.all([ - buildChannelNameMap([...serverIds]), - getProfilesByUserIds([...userIds]), - ]); + const profiles = await getProfilesByUserIds([...userIds]); return documents.map((message) => { const senderProfile = message.userId - ? profilesByUserId.get(message.userId) + ? profiles.get(message.userId) : null; const removedByProfile = message.removedBy - ? profilesByUserId.get(message.removedBy) + ? profiles.get(message.removedBy) : null; - const senderDisplay = - message.userName?.trim() || - senderProfile?.displayName || - shortId(message.userId); - - const serverDisplay = message.serverId - ? (serverNameMap.get(message.serverId) ?? shortId(message.serverId)) - : "No Server"; - - const channelDisplay = message.channelId - ? (channelNameMap.get(message.channelId) ?? - shortId(message.channelId)) - : "No Channel"; - - const removedByDisplay = message.removedBy - ? (removedByProfile?.displayName ?? shortId(message.removedBy)) - : undefined; - return { ...message, - senderDisplay, - serverDisplay, - channelDisplay, - removedByDisplay, + senderDisplay: + message.userName?.trim() || + senderProfile?.displayName || + shortId(message.userId), + serverDisplay: serverName, + channelDisplay: channelName, + removedByDisplay: message.removedBy + ? (removedByProfile?.displayName ?? shortId(message.removedBy)) + : undefined, }; }); } @@ -274,505 +237,175 @@ async function enrichModerationMessages( export default async function ModerationPage(props: { searchParams?: Promise>; }) { - // Await searchParams as required by Next.js 15 const searchParams = await props.searchParams; - // Middleware ensures auth; this double-checks moderator role - const { roles } = await requireModerator().catch(() => { - redirect("/"); - }); - const isAdmin = roles.isAdmin; - const params = parseModerationParams(searchParams); - const fetchMsgInput = buildFetchInput(params); - const data = await fetchModerationData(fetchMsgInput, params.serverFilter); - const documents = data.messages.items as unknown as ModerationMessage[]; - const enrichedDocuments = await enrichModerationMessages( - documents, - data.servers, - ); - const nextCursor = data.messages.nextCursor || undefined; - const badgeMap = await buildBadgeMapSimple(documents); - return ( -
-
- - - -
- ); -} - -async function fetchModerationData( - fetchMsgInput: ReturnType, - serverFilter?: string, -) { - const [messages, stats, channels, servers] = await Promise.all([ - listGlobalMessages(fetchMsgInput), - getBasicStats(), - getServerChannels(serverFilter), - getAllServers(), - ]); - return { messages, stats, channels, servers }; -} + const user = await requireAuth().catch(() => redirect("/")); + const roles = await checkUserRoles(user.$id); + const servers = await listModeratableServers(user.$id, roles); -function Header() { - return ( -
-
-
-
-
-

- Moderation panel built for fast triage. -

-

- Sweep messages across every server in seconds. Apply - filters, jump into context, and take action without - leaving this workspace. + const requestedServerId = + typeof searchParams?.serverId === "string" + ? searchParams.serverId + : undefined; + const selectedServer = + servers.find((s) => s.$id === requestedServerId) ?? servers[0] ?? null; + + if (!selectedServer) { + return ( +

+
+
+

+ No moderation access +

+

+ You need the manage messages permission in a server to + use moderation tools.

+
+ ); + } -
-
-

- Search -

-

- Find the message that matters. -

-
-
-

- Scope -

-

- Narrow by server, channel, or sender. -

-
-
-

- Action -

-

- Remove or review without changing context. -

-
-
-
- -
-
-
-

- Select a server to unlock channel filtering, or combine text - and user filters to surface a focused review queue. -

-
-
- Use removed-only when reviewing completed actions. -
-
- Use channel IDs when narrowing a large server. -
-
-
-
- ); -} - -function StatsGrid({ - stats, -}: { - stats: Awaited>; -}) { - const items = [ - { - label: "Servers monitored", - value: stats.servers, - icon: