diff --git a/src/__tests__/admin-audit.test.ts b/src/__tests__/admin-audit.test.ts index 064bf59..fc2baee 100644 --- a/src/__tests__/admin-audit.test.ts +++ b/src/__tests__/admin-audit.test.ts @@ -76,7 +76,7 @@ describe("adminAudit", () => { [null, null, null], ]); expect(entries[4]!.isAutomatic).toBe(true); - expect(mockGetUserList).toHaveBeenCalledWith({ userId: ["user_target", "user_mod"], limit: 100 }); + expect(mockGetUserList).toHaveBeenCalledWith({ userId: ["user_target", "user_mod"], limit: 2 }); }); it("filters by category and moderator and paginates", async () => { diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index 156fe91..131bb1c 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -968,14 +968,54 @@ describe("admin music-track catalog", () => { }); }); +describe("admin.reviewVerificationRequest", () => { + const checklist = { + meetsAuthenticity: true, + meetsCompleteProfile: true, + meetsGoodStanding: true, + meetsEstablishedPresence: true, + }; + + it("genehmigt nur, solange der Antrag noch offen ist (Status-Wechsel ist die Sperre)", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.verificationRequest.findUnique.mockResolvedValue({ petId: "pet-1", status: "PENDING" }); + + await caller.admin.reviewVerificationRequest({ requestId: "vr-1", ...checklist }); + + expect(mockPrisma.verificationRequest.updateMany).toHaveBeenCalledWith({ + where: { id: "vr-1", status: "PENDING" }, + data: expect.objectContaining({ status: "APPROVED", reviewedBy: "admin-1" }), + }); + expect(mockPrisma.pet.update).toHaveBeenCalledWith({ where: { id: "pet-1" }, data: { isVerified: true } }); + }); + + it("hat ein anderer Moderator gleichzeitig entschieden, bricht der zweite ab — ohne Haken, Log oder Nachricht", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.verificationRequest.findUnique.mockResolvedValue({ petId: "pet-1", status: "PENDING" }); + mockPrisma.verificationRequest.updateMany.mockResolvedValue({ count: 0 }); + + await expect(caller.admin.reviewVerificationRequest({ requestId: "vr-1", ...checklist })).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + expect(mockPrisma.pet.update).not.toHaveBeenCalled(); + expect(mockPrisma.moderationLog.create).not.toHaveBeenCalled(); + }); +}); + describe("admin.listReports", () => { it("includes the reported pet for profile reports so the panel can show its name", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); const createdAt = new Date("2026-10-05T08:00:00Z"); - mockPrisma.report.findMany.mockResolvedValue([ - { id: "r1", reason: "SPAM", targetPostId: null, targetPetId: "pet-9", createdAt, reporter: { id: "pet-1", name: "Mia" } }, - ]); + mockPrisma.report.findMany.mockImplementation(({ where }: { where: { reason: { in: string[] } } }) => + Promise.resolve( + where.reason.in.includes("SPAM") + ? [{ id: "r1", reason: "SPAM", targetPostId: null, targetPetId: "pet-9", createdAt, reporter: { id: "pet-1", name: "Mia" } }] + : [] + ) + ); mockPrisma.pet.findMany.mockResolvedValue([{ id: "pet-9", name: "Bello", nickname: null, avatarKey: null }]); const [report] = await caller.admin.listReports(); @@ -984,6 +1024,26 @@ describe("admin.listReports", () => { expect(report!.post).toBeNull(); expect(mockPrisma.post.findMany).not.toHaveBeenCalled(); }); + + it("lädt nach Schwere: ältere schwere Meldungen fallen nicht hinter viele neue leichte zurück", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + const mk = (id: string, reason: string) => ({ + id, reason, targetPostId: null, targetPetId: null, createdAt: new Date(), reporter: { id: "pet-1", name: "Mia" }, + }); + mockPrisma.report.findMany + .mockResolvedValueOnce([mk("high-old", "HARASSMENT")]) + .mockResolvedValueOnce([mk("medium", "MISINFORMATION")]) + .mockResolvedValueOnce([mk("low", "SPAM")]); + + const reports = await caller.admin.listReports(); + + const calls = mockPrisma.report.findMany.mock.calls.map((c) => c[0]); + expect(calls[0]).toMatchObject({ where: { reason: { in: ["HARASSMENT", "INAPPROPRIATE"] } }, take: 100 }); + expect(calls[1]).toMatchObject({ where: { reason: { in: ["MISINFORMATION"] } }, take: 99 }); + expect(calls[2]).toMatchObject({ where: { reason: { in: ["SPAM", "OTHER"] } }, take: 98 }); + expect(reports.map((r) => r.id)).toEqual(["high-old", "medium", "low"]); + }); }); describe("admin.listModerators", () => { diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index dd22362..05982f8 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -381,7 +381,7 @@ interface MockPrismaClient { storyMention: { createMany: MockFn; findMany: MockFn; deleteMany: MockFn }; lostReport: { findFirst: MockFn; findUnique: MockFn; findMany: MockFn; count: MockFn; create: MockFn; update: MockFn; delete: MockFn }; petCaretaker: { findUnique: MockFn; findMany: MockFn; upsert: MockFn; updateMany: MockFn; delete: MockFn; deleteMany: MockFn }; - verificationRequest: { findFirst: MockFn; findMany: MockFn; count: MockFn }; + verificationRequest: { findFirst: MockFn; findMany: MockFn; count: MockFn; findUnique: MockFn; updateMany: MockFn }; pushToken: { upsert: MockFn; findMany: MockFn; deleteMany: MockFn }; message: { findFirst: MockFn; findMany: MockFn; count: MockFn }; storyView: MockStoryViewClient; @@ -528,6 +528,8 @@ export function createMockPrisma(): MockPrismaClient { findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0), + findUnique: vi.fn(), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, message: { findFirst: vi.fn().mockResolvedValue(null), diff --git a/src/components/admin/pets/PetDetailSheet.tsx b/src/components/admin/pets/PetDetailSheet.tsx index f36f238..7f4bc8a 100644 --- a/src/components/admin/pets/PetDetailSheet.tsx +++ b/src/components/admin/pets/PetDetailSheet.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { ExternalLink, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { useTRPC } from "@/trpc/client"; @@ -17,17 +17,13 @@ import { ReportStatsDetails } from "@/components/admin/ReportStats"; /** Detailansicht eines Tiers in der Admin-Tierverwaltung. */ export function PetDetailSheet({ petId, onClose }: { petId: string | null; onClose: () => void }) { const trpc = useTRPC(); - const qc = useQueryClient(); const { data, isLoading } = useQuery({ ...trpc.petAdmin.detail.queryOptions({ petId: petId ?? "" }), enabled: !!petId, }); - const refresh = () => { - void qc.invalidateQueries(trpc.petAdmin.pathFilter()); - }; - const setVerified = useMutation(trpc.admin.setPetVerified.mutationOptions({ onSuccess: refresh, onError: (e) => toast.error(e.message) })); - const setFounding = useMutation(trpc.petAdmin.setFoundingMember.mutationOptions({ onSuccess: refresh, onError: (e) => toast.error(e.message) })); + const setVerified = useMutation(trpc.admin.setPetVerified.mutationOptions({ onError: (e) => toast.error(e.message) })); + const setFounding = useMutation(trpc.petAdmin.setFoundingMember.mutationOptions({ onError: (e) => toast.error(e.message) })); const pet = data?.pet; @@ -136,17 +132,12 @@ export function PetDetailSheet({ petId, onClose }: { petId: string | null; onClo qualifiedCount={pet.owner.qualifiedReferralCount} ranks={data.ranks} achievements={pet.owner.rankAchievements} - onChanged={refresh} /> { - refresh(); - onClose(); - }} + onDeleted={onClose} /> )} diff --git a/src/components/admin/pets/PetModerationSection.tsx b/src/components/admin/pets/PetModerationSection.tsx index 4c56af6..cd2465b 100644 --- a/src/components/admin/pets/PetModerationSection.tsx +++ b/src/components/admin/pets/PetModerationSection.tsx @@ -28,10 +28,10 @@ type FieldKey = (typeof FIELDS)[number]["key"]; const NOTIFIED_LABEL = { dm: "Halter per Direktnachricht informiert.", email: "Halter per E-Mail informiert.", none: "Halter nicht benachrichtigt." }; -type Props = { petId: string; petName: string; onChanged: () => void; onDeleted: () => void }; +type Props = { petId: string; petName: string; onDeleted: () => void }; /** Inhalte leeren (ohne Löschen) und Tier endgültig löschen — jeweils mit Begründung. */ -export function PetModerationSection({ petId, petName, onChanged, onDeleted }: Props) { +export function PetModerationSection({ petId, petName, onDeleted }: Props) { const trpc = useTRPC(); const [fields, setFields] = useState>(new Set()); const [reason, setReason] = useState(""); @@ -45,7 +45,6 @@ export function PetModerationSection({ petId, petName, onChanged, onDeleted }: P toast.success("Inhalte entfernt"); setFields(new Set()); setReason(""); - onChanged(); }, onError: (e) => toast.error(e.message), }) diff --git a/src/components/admin/pets/PetRankSection.tsx b/src/components/admin/pets/PetRankSection.tsx index 7cc097c..5d326fd 100644 --- a/src/components/admin/pets/PetRankSection.tsx +++ b/src/components/admin/pets/PetRankSection.tsx @@ -23,14 +23,13 @@ type Props = { qualifiedCount: number; ranks: Rank[]; achievements: Achievement[]; - onChanged: () => void; }; /** * Empfehlungsränge des Owners — gelten für ALLE seine Tiere. Entziehen ist * dauerhaft (bis „Freigeben“), Vergeben geht auch ohne erreichte Schwelle. */ -export function PetRankSection({ ownerId, ownerPetCount, qualifiedCount, ranks, achievements, onChanged }: Props) { +export function PetRankSection({ ownerId, ownerPetCount, qualifiedCount, ranks, achievements }: Props) { const trpc = useTRPC(); const [reason, setReason] = useState(""); const byRank = new Map(achievements.map((a) => [a.rankId, a])); @@ -38,7 +37,6 @@ export function PetRankSection({ ownerId, ownerPetCount, qualifiedCount, ranks, const done = (message: string) => { toast.success(message); setReason(""); - onChanged(); }; const revoke = useMutation(trpc.referralAdmin.revokeRank.mutationOptions({ onSuccess: () => done("Rang entzogen"), onError: (e) => toast.error(e.message) })); const grant = useMutation( diff --git a/src/lib/admin/clerk-profiles.ts b/src/lib/admin/clerk-profiles.ts new file mode 100644 index 0000000..08bdf73 --- /dev/null +++ b/src/lib/admin/clerk-profiles.ts @@ -0,0 +1,41 @@ +import "server-only"; +import { clerkClient } from "@clerk/nextjs/server"; + +/** Clerk erlaubt höchstens so viele IDs pro getUserList-Abfrage. */ +const CLERK_MAX_IDS = 500; + +export interface ClerkProfile { + name: string | null; + email: string | null; + createdAt: Date; +} + +/** + * Name, E-Mail und Registrierungsdatum zu Owner-IDs — Owner speichert lokal + * nur die Clerk-ID (Gitea #6). Ist Clerk nicht erreichbar, kommt eine leere + * Map zurück: Admin-Listen bleiben nutzbar, nur ohne Namen. + */ +export async function clerkProfiles(ids: string[]): Promise> { + const unique = [...new Set(ids)].slice(0, CLERK_MAX_IDS); + if (unique.length === 0) return new Map(); + try { + const { data } = await (await clerkClient()).users.getUserList({ userId: unique, limit: unique.length }); + return new Map( + data.map((u) => [ + u.id, + { + name: [u.firstName, u.lastName].filter(Boolean).join(" ") || null, + email: + u.primaryEmailAddress?.emailAddress ?? + u.emailAddresses.find((e) => e.id === u.primaryEmailAddressId)?.emailAddress ?? + u.emailAddresses[0]?.emailAddress ?? + null, + createdAt: new Date(u.createdAt), + }, + ]) + ); + } catch (err) { + console.error("[admin] Clerk profile lookup failed", err); + return new Map(); + } +} diff --git a/src/trpc/routers/admin-audit.ts b/src/trpc/routers/admin-audit.ts index ed35399..261a092 100644 --- a/src/trpc/routers/admin-audit.ts +++ b/src/trpc/routers/admin-audit.ts @@ -1,6 +1,6 @@ import "server-only"; import { z } from "zod"; -import { clerkClient } from "@clerk/nextjs/server"; +import { clerkProfiles } from "@/lib/admin/clerk-profiles"; import { AdminRoleType, type PrismaClient } from "@prisma/client"; import { router, protectedProcedure } from "@/trpc/init"; import { assertAdmin } from "@/trpc/routers/admin"; @@ -13,21 +13,13 @@ const SYSTEM_MODERATOR = "SYSTEM"; const unique = (values: (T | null | undefined)[]) => [...new Set(values.filter((v): v is T => v != null))]; -/** Anzeigenamen von Clerk-Usern (Vor- + Nachname, sonst E-Mail). Fehler bei Clerk → leere Map statt kaputtem Protokoll. */ +/** Anzeigenamen von Clerk-Usern (Vor- + Nachname, sonst E-Mail, sonst ID). */ async function clerkNames(ids: string[]): Promise> { - if (ids.length === 0) return new Map(); - try { - const clerk = await clerkClient(); - const { data } = await clerk.users.getUserList({ userId: ids.slice(0, CLERK_LOOKUP_LIMIT), limit: CLERK_LOOKUP_LIMIT }); - return new Map( - data.map((u) => [ - u.id, - [u.firstName, u.lastName].filter(Boolean).join(" ") || u.primaryEmailAddress?.emailAddress || u.emailAddresses[0]?.emailAddress || u.id, - ]) - ); - } catch { - return new Map(); - } + const profiles = await clerkProfiles(ids.slice(0, CLERK_LOOKUP_LIMIT)); + return new Map(ids.flatMap((id) => { + const p = profiles.get(id); + return p ? [[id, p.name ?? p.email ?? id] as const] : []; + })); } type LogRow = { id: string; action: string; targetType: string; targetId: string; reason: string | null; moderatorId: string }; diff --git a/src/trpc/routers/admin-procedures/dashboard.ts b/src/trpc/routers/admin-procedures/dashboard.ts index d4d3219..37391ed 100644 --- a/src/trpc/routers/admin-procedures/dashboard.ts +++ b/src/trpc/routers/admin-procedures/dashboard.ts @@ -49,6 +49,17 @@ function countByBucket(timestamps: Date[], buckets: Date[], granularity: Granula return buckets.map((b) => counts.get(b.getTime()) ?? 0); } +/** Neue Einträge pro Zeitabschnitt (Beiträge, Kommentare, Pfoten …) als Zeitreihe ohne Lücken. */ +async function countCreatedOverTime( + granularity: Granularity, + findSince: (since: Date) => Promise<{ createdAt: Date }[]> +): Promise<{ date: string; value: number }[]> { + const buckets = buildBuckets(granularity); + const rows = await findSince(buckets[0]!); + const counts = countByBucket(rows.map((r) => r.createdAt), buckets, granularity); + return buckets.map((date, i) => ({ date: date.toISOString(), value: counts[i]! })); +} + export const adminDashboardProcedures = { // ─── Dashboard ─────────────────────────────────────────────────────────────── getStats: protectedProcedure.query(async ({ ctx }) => { @@ -101,13 +112,9 @@ export const adminDashboardProcedures = { .input(z.object({ granularity: z.enum(["day", "week", "month"]) })) .query(async ({ ctx, input }) => { await assertAdmin(ctx.prisma, ctx.userId); - const buckets = buildBuckets(input.granularity); - const posts = await ctx.prisma.post.findMany({ - where: { createdAt: { gte: buckets[0] } }, - select: { createdAt: true }, - }); - const counts = countByBucket(posts.map((p) => p.createdAt), buckets, input.granularity); - return buckets.map((date, i) => ({ date: date.toISOString(), value: counts[i]! })); + return countCreatedOverTime(input.granularity, (since) => + ctx.prisma.post.findMany({ where: { createdAt: { gte: since } }, select: { createdAt: true } }) + ); }), /** Cumulative total owners per bucket — the growth curve, not just new signups. */ @@ -134,13 +141,9 @@ export const adminDashboardProcedures = { .input(z.object({ granularity: z.enum(["day", "week", "month"]) })) .query(async ({ ctx, input }) => { await assertAdmin(ctx.prisma, ctx.userId); - const buckets = buildBuckets(input.granularity); - const comments = await ctx.prisma.comment.findMany({ - where: { createdAt: { gte: buckets[0] } }, - select: { createdAt: true }, - }); - const counts = countByBucket(comments.map((c) => c.createdAt), buckets, input.granularity); - return buckets.map((date, i) => ({ date: date.toISOString(), value: counts[i]! })); + return countCreatedOverTime(input.granularity, (since) => + ctx.prisma.comment.findMany({ where: { createdAt: { gte: since } }, select: { createdAt: true } }) + ); }), /** New reactions ("likes") per bucket over the trailing window. */ @@ -148,13 +151,9 @@ export const adminDashboardProcedures = { .input(z.object({ granularity: z.enum(["day", "week", "month"]) })) .query(async ({ ctx, input }) => { await assertAdmin(ctx.prisma, ctx.userId); - const buckets = buildBuckets(input.granularity); - const reactions = await ctx.prisma.reaction.findMany({ - where: { createdAt: { gte: buckets[0] } }, - select: { createdAt: true }, - }); - const counts = countByBucket(reactions.map((r) => r.createdAt), buckets, input.granularity); - return buckets.map((date, i) => ({ date: date.toISOString(), value: counts[i]! })); + return countCreatedOverTime(input.granularity, (since) => + ctx.prisma.reaction.findMany({ where: { createdAt: { gte: since } }, select: { createdAt: true } }) + ); }), /** diff --git a/src/trpc/routers/admin-procedures/reports.ts b/src/trpc/routers/admin-procedures/reports.ts index 58c2cf5..8b9c016 100644 --- a/src/trpc/routers/admin-procedures/reports.ts +++ b/src/trpc/routers/admin-procedures/reports.ts @@ -24,19 +24,35 @@ const REPORT_SEVERITY: Record = { OTHER: "LOW", }; const SEVERITY_RANK: Record = { HIGH: 2, MEDIUM: 1, LOW: 0 }; +const SEVERITY_ORDER: ReportSeverity[] = ["HIGH", "MEDIUM", "LOW"]; +const REASONS_BY_SEVERITY = Object.fromEntries( + SEVERITY_ORDER.map((severity) => [ + severity, + (Object.keys(REPORT_SEVERITY) as ReportReason[]).filter((reason) => REPORT_SEVERITY[reason] === severity), + ]) +) as Record; +/** Höchstzahl offener Meldungen in der Warteschlange. */ +const QUEUE_LIMIT = 100; export const adminReportsProcedures = { // ─── Reports ───────────────────────────────────────────────────────────────── listReports: protectedProcedure.query(async ({ ctx }) => { await assertAdmin(ctx.prisma, ctx.userId); - const reports = await ctx.prisma.report.findMany({ - include: { - reporter: { select: petIdentitySelect }, - }, - orderBy: { createdAt: "desc" }, - take: 100, - }); + // Schwerste zuerst laden und je Stufe nachfüllen, bis das Limit voll ist — + // sonst fiele eine ältere schwere Meldung hinter viele neue leichte zurück. + const reports = []; + for (const severity of SEVERITY_ORDER) { + if (reports.length >= QUEUE_LIMIT) break; + reports.push( + ...(await ctx.prisma.report.findMany({ + where: { reason: { in: REASONS_BY_SEVERITY[severity] } }, + include: { reporter: { select: petIdentitySelect } }, + orderBy: { createdAt: "desc" }, + take: QUEUE_LIMIT - reports.length, + })) + ); + } // targetPostId is a raw string — no Prisma relation, so fetch posts separately const postIds = [...new Set( diff --git a/src/trpc/routers/admin-procedures/system.ts b/src/trpc/routers/admin-procedures/system.ts index f112df1..31900f3 100644 --- a/src/trpc/routers/admin-procedures/system.ts +++ b/src/trpc/routers/admin-procedures/system.ts @@ -2,7 +2,7 @@ import "server-only"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { AdminRoleType } from "@prisma/client"; -import { clerkClient } from "@clerk/nextjs/server"; +import { clerkProfiles } from "@/lib/admin/clerk-profiles"; import { protectedProcedure } from "@/trpc/init"; import { modLog } from "@/lib/admin/mod-log"; import { isMaintenanceModeEnabled, setMaintenanceMode } from "@/lib/maintenance"; @@ -28,26 +28,10 @@ export const adminSystemProcedures = { }); // Namen + E-Mail für die Seite „Team & Rollen“ (statt nackter Clerk-IDs). Clerk-Ausfall → ohne Namen. const ids = [...new Set(roles.flatMap((r) => [r.ownerId, r.grantedBy]).filter((id): id is string => !!id))]; - let people = new Map(); - if (ids.length > 0) { - try { - const { data } = await (await clerkClient()).users.getUserList({ userId: ids, limit: ids.length }); - people = new Map( - data.map((u) => [ - u.id, - { - name: [u.firstName, u.lastName].filter(Boolean).join(" ") || null, - email: u.emailAddresses.find((e) => e.id === u.primaryEmailAddressId)?.emailAddress ?? null, - }, - ]) - ); - } catch { - // Liste bleibt nutzbar, nur ohne Namen. - } - } + const people = await clerkProfiles(ids); return roles.map((r) => ({ ...r, - person: people.get(r.ownerId) ?? { name: null, email: null }, + person: { name: people.get(r.ownerId)?.name ?? null, email: people.get(r.ownerId)?.email ?? null }, grantedByName: r.grantedBy ? (people.get(r.grantedBy)?.name ?? null) : null, })); }), diff --git a/src/trpc/routers/admin-procedures/users.ts b/src/trpc/routers/admin-procedures/users.ts index 488cbc4..b5bfeaf 100644 --- a/src/trpc/routers/admin-procedures/users.ts +++ b/src/trpc/routers/admin-procedures/users.ts @@ -14,6 +14,7 @@ import { buildGreeting, renderBrandedMailHtml } from "@/lib/mail-template"; import { deleteOwnerAccount } from "@/lib/delete-account"; import { reportStatsByOwner, withFalseReporterFlag } from "@/lib/report-decisions"; import { resolveAdminRole, assertAdmin, assertCanModerate, rethrowNotFound } from "@/trpc/routers/admin-procedures/guards"; +import { clerkProfiles } from "@/lib/admin/clerk-profiles"; /** Nutzer: Liste, Sperren, Shadowban, Verwarnungen, Konto löschen, Datenauskunft. Teil von `trpc.admin.*` (admin.ts). */ @@ -98,31 +99,16 @@ export const adminUsersProcedures = { const hasNext = owners.length > input.limit; const items = hasNext ? owners.slice(0, input.limit) : owners; - // Batch-fetch Clerk profile data (email/name) — Owner only stores the - // Clerk userId locally, everything else lives in Clerk (Gitea #6). - const clerkUsers = items.length > 0 - ? (await clerk.users.getUserList({ userId: items.map((o) => o.id), limit: items.length })).data - : []; - const clerkById = new Map(clerkUsers.map((u) => [u.id, u])); - const reportStats = await reportStatsByOwner(ctx.prisma, items.map((o) => o.id)); - - const usersWithClerkData = items.map((owner) => { - const clerkUser = clerkById.get(owner.id); - const primaryEmail = clerkUser?.emailAddresses.find( - (e) => e.id === clerkUser.primaryEmailAddressId - )?.emailAddress; - return { - ...owner, - reportStats: withFalseReporterFlag(reportStats.get(owner.id)!), - clerk: clerkUser - ? { - email: primaryEmail ?? null, - name: [clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") || null, - createdAt: new Date(clerkUser.createdAt), - } - : null, - }; - }); + const ownerIds = items.map((o) => o.id); + const [profiles, reportStats] = await Promise.all([ + clerkProfiles(ownerIds), + reportStatsByOwner(ctx.prisma, ownerIds), + ]); + const usersWithClerkData = items.map((owner) => ({ + ...owner, + reportStats: withFalseReporterFlag(reportStats.get(owner.id)!), + clerk: profiles.get(owner.id) ?? null, + })); return { users: usersWithClerkData, nextCursor: hasNext ? items[items.length - 1]!.id : null }; }), diff --git a/src/trpc/routers/admin-procedures/verification.ts b/src/trpc/routers/admin-procedures/verification.ts index 560f6ef..f4f4e7d 100644 --- a/src/trpc/routers/admin-procedures/verification.ts +++ b/src/trpc/routers/admin-procedures/verification.ts @@ -128,9 +128,11 @@ export const adminVerificationProcedures = { ? (input.note ?? null) : [`Fehlende Kriterien: ${missing.join(", ")}`, input.note].filter(Boolean).join(" — "); - await ctx.prisma.$transaction([ - ctx.prisma.verificationRequest.update({ - where: { id: input.requestId }, + // Der Status-Wechsel ist die Sperre: Entscheiden zwei Moderatoren gleichzeitig, + // trifft das updateMany nur beim ersten noch einen offenen Antrag. + await ctx.prisma.$transaction(async (tx) => { + const { count } = await tx.verificationRequest.updateMany({ + where: { id: input.requestId, status: "PENDING" }, data: { status: allMet ? "APPROVED" : "REJECTED", meetsAuthenticity: input.meetsAuthenticity, @@ -141,17 +143,18 @@ export const adminVerificationProcedures = { reviewedBy: ctx.userId, reviewedAt: new Date(), }, - }), - ...(allMet - ? [ctx.prisma.pet.update({ where: { id: request.petId }, data: { isVerified: true } })] - : []), - modLog(ctx.prisma, ctx, callerRole, { + }); + if (count === 0) { + throw new TRPCError({ code: "BAD_REQUEST", message: "This request was already reviewed." }); + } + if (allMet) await tx.pet.update({ where: { id: request.petId }, data: { isVerified: true } }); + await modLog(tx, ctx, callerRole, { action: allMet ? "APPROVE_VERIFICATION_REQUEST" : "REJECT_VERIFICATION_REQUEST", targetType: "PET", targetId: request.petId, reason: reviewNote, - }), - ]); + }); + }); // Best-effort — the review itself already succeeded and is persisted; // a failed notification must not roll that back or fail the request.