From 2d656bd77910fbb7dd8ec97e482809f1d9055e7e Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Wed, 12 Aug 2026 08:38:25 +0200 Subject: [PATCH] feat(admin): add SUPPORT role, user search/filter, and moderation audit gaps Gitea #10 follow-up, Phase 1: - AdminRoleType gains SUPPORT (same access as MODERATOR for now, pending a permission-matrix pass to scope it down) - listUsers gains search (Clerk query + local pet-name match), ban-status, and role filters - ModerationLog now also captures ad create/update/delete and role grant/revoke, which previously left no audit trail - new getModeratorThroughput query surfaces per-moderator action counts on the Moderators page --- prisma/schema.prisma | 7 +- src/app/p/[secret]/moderators/page.tsx | 13 +- src/app/p/[secret]/users/page.tsx | 44 ++++++- src/trpc/routers/admin.ts | 173 ++++++++++++++++++++----- 4 files changed, 201 insertions(+), 36 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 80d8225..6587f14 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -543,6 +543,9 @@ model Notification { enum AdminRoleType { SUPER_ADMIN MODERATOR + // Full admin-panel access, same as MODERATOR today — awaiting the + // permission-matrix pass to scope this down to a narrower read/act set. + SUPPORT } model AdminRole { @@ -634,8 +637,8 @@ model AdRepost { model ModerationLog { id String @id @default(cuid()) - action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE - targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE + action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE + targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT targetId String reason String? @db.VarChar(500) moderatorId String // ownerId diff --git a/src/app/p/[secret]/moderators/page.tsx b/src/app/p/[secret]/moderators/page.tsx index e812475..7704271 100644 --- a/src/app/p/[secret]/moderators/page.tsx +++ b/src/app/p/[secret]/moderators/page.tsx @@ -20,6 +20,10 @@ export default function AdminModeratorsPage() { const [revokeTarget, setRevokeTarget] = useState(null); const { data: mods = [], isLoading } = useQuery(trpc.admin.listModerators.queryOptions()); + const { data: throughput = [] } = useQuery( + trpc.admin.getModeratorThroughput.queryOptions({ days: 30 }) + ); + const actionCountByModeratorId = new Map(throughput.map((t) => [t.moderatorId, t.actionCount])); const invalidate = () => void qc.invalidateQueries({ queryKey: trpc.admin.listModerators.queryKey() }); @@ -79,13 +83,19 @@ export default function AdminModeratorsPage() { className={ mod.role === AdminRoleType.SUPER_ADMIN ? "bg-orange-700 text-orange-100 text-[10px]" - : "bg-zinc-700 text-zinc-200 text-[10px]" + : mod.role === AdminRoleType.SUPPORT + ? "bg-blue-700 text-blue-100 text-[10px]" + : "bg-zinc-700 text-zinc-200 text-[10px]" } > {mod.role} {pet &&

Pet: {pet.name}

} +

+ {actionCountByModeratorId.get(mod.ownerId) ?? 0} action + {actionCountByModeratorId.get(mod.ownerId) === 1 ? "" : "s"} (last 30 days) +

Granted {new Date(mod.grantedAt).toLocaleDateString()} {mod.grantedBy && ` by ${mod.grantedBy.slice(0, 12)}…`} @@ -129,6 +139,7 @@ export default function AdminModeratorsPage() { + Support Moderator Super Admin diff --git a/src/app/p/[secret]/users/page.tsx b/src/app/p/[secret]/users/page.tsx index f9cf852..5abadcf 100644 --- a/src/app/p/[secret]/users/page.tsx +++ b/src/app/p/[secret]/users/page.tsx @@ -11,6 +11,11 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { AdminRoleType } from "@prisma/client"; + +type BanStatusFilter = "all" | "banned" | "active"; +type RoleFilter = "all" | "none" | AdminRoleType; export default function AdminUsersPage() { const trpc = useTRPC(); @@ -21,10 +26,13 @@ export default function AdminUsersPage() { const [unbanTarget, setUnbanTarget] = useState(null); const [banReason, setBanReason] = useState(""); const [banExpiry, setBanExpiry] = useState(""); + const [search, setSearch] = useState(""); + const [banStatus, setBanStatus] = useState("all"); + const [role, setRole] = useState("all"); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery( trpc.admin.listUsers.infiniteQueryOptions( - { limit: 20 }, + { limit: 20, search: search || undefined, banStatus, role }, { getNextPageParam: (p) => p.nextCursor, initialCursor: undefined } ) ); @@ -73,7 +81,39 @@ export default function AdminUsersPage() { return (

-

Users

+
+

Users

+
+ setSearch(e.target.value)} + className="w-64 bg-zinc-900 border-zinc-700 text-zinc-100 placeholder:text-zinc-500" + /> + + +
+
{isLoading ? (
diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index 24584ee..beebffa 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -288,10 +288,48 @@ export const adminRouter = router({ // ─── Users / bans ──────────────────────────────────────────────────────────── listUsers: protectedProcedure - .input(z.object({ cursor: z.string().nullish(), limit: z.number().min(1).max(50).default(20) })) + .input( + z.object({ + cursor: z.string().nullish(), + limit: z.number().min(1).max(50).default(20), + search: z.string().optional(), + banStatus: z.enum(["all", "banned", "active"]).default("all"), + role: z.union([z.literal("all"), z.literal("none"), z.nativeEnum(AdminRoleType)]).default("all"), + }) + ) .query(async ({ ctx, input }) => { await assertAdmin(ctx.prisma, ctx.userId); + const clerk = await clerkClient(); + + // Owner has no local name/email (Clerk owns that) — resolve matching + // owner IDs by unioning Clerk's own query search (name/email/username) + // with a local Pet.name match, since admins commonly search by pet + // name too. Clerk search is capped at 100 matches for now. + let searchedIds: string[] | null = null; + if (input.search) { + const [clerkMatches, petMatches] = await Promise.all([ + clerk.users.getUserList({ query: input.search, limit: 100 }), + ctx.prisma.pet.findMany({ + where: { name: { contains: input.search, mode: "insensitive" } }, + select: { ownerId: true }, + }), + ]); + searchedIds = [ + ...new Set([...clerkMatches.data.map((u) => u.id), ...petMatches.map((p) => p.ownerId)]), + ]; + if (searchedIds.length === 0) { + return { users: [], nextCursor: null }; + } + } + const owners = await ctx.prisma.owner.findMany({ + where: { + ...(searchedIds ? { id: { in: searchedIds } } : {}), + ...(input.banStatus === "banned" ? { ban: { isNot: null } } : {}), + ...(input.banStatus === "active" ? { ban: null } : {}), + ...(input.role === "none" ? { adminRole: null } : {}), + ...(input.role !== "all" && input.role !== "none" ? { adminRole: { role: input.role } } : {}), + }, include: { pets: { select: { id: true, name: true, avatarKey: true }, take: 3 }, ban: true, @@ -307,7 +345,6 @@ export const adminRouter = router({ // Batch-fetch Clerk profile data (email/name) — Owner only stores the // Clerk userId locally, everything else lives in Clerk (Gitea #6). - const clerk = await clerkClient(); const clerkUsers = items.length > 0 ? (await clerk.users.getUserList({ userId: items.map((o) => o.id), limit: items.length })).data : []; @@ -666,19 +703,34 @@ export const adminRouter = router({ ) .mutation(async ({ ctx, input }) => { await assertAdmin(ctx.prisma, ctx.userId); - return ctx.prisma.advertisement.create({ - data: { - title: input.title, - caption: input.caption ?? null, - imageKey: input.imageKey ?? null, - targetUrl: input.targetUrl || null, - isActive: input.isActive, - species: input.species, - breeds: input.breeds, - startDate: input.startDate ? new Date(input.startDate) : null, - endDate: input.endDate ? new Date(input.endDate) : null, - createdBy: ctx.userId, - }, + // Interactive transaction (not the array form used elsewhere in this file) + // because the log entry needs the DB-generated ad id, which only exists + // after the create resolves. + return ctx.prisma.$transaction(async (tx) => { + const ad = await tx.advertisement.create({ + data: { + title: input.title, + caption: input.caption ?? null, + imageKey: input.imageKey ?? null, + targetUrl: input.targetUrl || null, + isActive: input.isActive, + species: input.species, + breeds: input.breeds, + startDate: input.startDate ? new Date(input.startDate) : null, + endDate: input.endDate ? new Date(input.endDate) : null, + createdBy: ctx.userId, + }, + }); + await tx.moderationLog.create({ + data: { + action: "CREATE_AD", + targetType: "AD", + targetId: ad.id, + reason: input.title, + moderatorId: ctx.userId, + }, + }); + return ad; }); }), @@ -700,22 +752,43 @@ export const adminRouter = router({ .mutation(async ({ ctx, input }) => { await assertAdmin(ctx.prisma, ctx.userId); const { id, targetUrl, startDate, endDate, ...rest } = input; - return ctx.prisma.advertisement.update({ - where: { id }, - data: { - ...rest, - targetUrl: targetUrl === "" ? null : targetUrl, - startDate: startDate ? new Date(startDate) : startDate === null ? null : undefined, - endDate: endDate ? new Date(endDate) : endDate === null ? null : undefined, - }, - }); + const [ad] = await ctx.prisma.$transaction([ + ctx.prisma.advertisement.update({ + where: { id }, + data: { + ...rest, + targetUrl: targetUrl === "" ? null : targetUrl, + startDate: startDate ? new Date(startDate) : startDate === null ? null : undefined, + endDate: endDate ? new Date(endDate) : endDate === null ? null : undefined, + }, + }), + ctx.prisma.moderationLog.create({ + data: { + action: "UPDATE_AD", + targetType: "AD", + targetId: id, + moderatorId: ctx.userId, + }, + }), + ]); + return ad; }), deleteAd: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { await assertAdmin(ctx.prisma, ctx.userId); - await ctx.prisma.advertisement.delete({ where: { id: input.id } }); + await ctx.prisma.$transaction([ + ctx.prisma.advertisement.delete({ where: { id: input.id } }), + ctx.prisma.moderationLog.create({ + data: { + action: "DELETE_AD", + targetType: "AD", + targetId: input.id, + moderatorId: ctx.userId, + }, + }), + ]); return { success: true }; }), @@ -768,18 +841,40 @@ export const adminRouter = router({ .input(z.object({ ownerId: z.string().min(1), role: z.nativeEnum(AdminRoleType) })) .mutation(async ({ ctx, input }) => { await assertAdmin(ctx.prisma, ctx.userId, true); - return ctx.prisma.adminRole.upsert({ - where: { ownerId: input.ownerId }, - create: { ownerId: input.ownerId, role: input.role, grantedBy: ctx.userId }, - update: { role: input.role, grantedBy: ctx.userId, grantedAt: new Date() }, - }); + const [role] = await ctx.prisma.$transaction([ + ctx.prisma.adminRole.upsert({ + where: { ownerId: input.ownerId }, + create: { ownerId: input.ownerId, role: input.role, grantedBy: ctx.userId }, + update: { role: input.role, grantedBy: ctx.userId, grantedAt: new Date() }, + }), + ctx.prisma.moderationLog.create({ + data: { + action: "GRANT_ROLE", + targetType: "USER", + targetId: input.ownerId, + reason: input.role, + moderatorId: ctx.userId, + }, + }), + ]); + return role; }), revokeRole: protectedProcedure .input(z.object({ ownerId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { await assertAdmin(ctx.prisma, ctx.userId, true); - await ctx.prisma.adminRole.delete({ where: { ownerId: input.ownerId } }); + await ctx.prisma.$transaction([ + ctx.prisma.adminRole.delete({ where: { ownerId: input.ownerId } }), + ctx.prisma.moderationLog.create({ + data: { + action: "REVOKE_ROLE", + targetType: "USER", + targetId: input.ownerId, + moderatorId: ctx.userId, + }, + }), + ]); return { success: true }; }), @@ -793,6 +888,22 @@ export const adminRouter = router({ }); }), + /** Action count per moderator over the trailing window — throughput for the Moderators page. */ + getModeratorThroughput: protectedProcedure + .input(z.object({ days: z.number().min(1).max(365).default(30) })) + .query(async ({ ctx, input }) => { + await assertAdmin(ctx.prisma, ctx.userId); + const since = new Date(Date.now() - input.days * 24 * 60 * 60 * 1000); + const grouped = await ctx.prisma.moderationLog.groupBy({ + by: ["moderatorId"], + where: { createdAt: { gte: since } }, + _count: true, + }); + return grouped + .map((g) => ({ moderatorId: g.moderatorId, actionCount: g._count })) + .sort((a, b) => b.actionCount - a.actionCount); + }), + // ─── Invite codes ───────────────────────────────────────────────────────────── listInviteCodes: protectedProcedure .input(z.object({ limit: z.number().min(1).max(200).default(100) }))