fix(admin): Warteschlange nach Schwere, Verifizierung atomar, weniger Doppelcode

- listReports lädt die schwersten Meldungen zuerst und füllt je Stufe bis
  100 auf — eine ältere schwere Meldung fällt nicht mehr hinter viele neue
  leichte zurück.
- reviewVerificationRequest: der Status-Wechsel (updateMany auf PENDING) ist
  die Sperre; entscheiden zwei Moderatoren gleichzeitig, bricht der zweite
  ohne Haken, Log und Nachricht ab.
- countCreatedOverTime für die drei gleichen Dashboard-Zeitreihen.
- clerkProfiles (src/lib/admin/clerk-profiles.ts) statt drei eigener
  Clerk-Abfragen in Nutzerliste, Team & Rollen und Protokoll.
- Tier-Detail: eigenes Neuladen entfällt (AdminAutoRefresh übernimmt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 07:39:02 +02:00
1 parent de6b36e3d3
commit 5cf3deb006
13 files changed
+192 -121

No files matched your search

+1 -1
View File
@@ -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 () => {
+63 -3
View File
@@ -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", () => {
+3 -1
View File
@@ -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),
+4 -13
View File
@@ -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}
/>
<PetModerationSection
petId={pet.id}
petName={pet.name}
onChanged={refresh}
onDeleted={() => {
refresh();
onClose();
}}
onDeleted={onClose}
/>
</div>
)}
@@ -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<Set<FieldKey>>(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),
})
+1 -3
View File
@@ -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(
+41
View File
@@ -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<Map<string, ClerkProfile>> {
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();
}
}
+7 -15
View File
@@ -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 = <T,>(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<Map<string, string>> {
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 };
+20 -21
View File
@@ -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 } })
);
}),
/**
+23 -7
View File
@@ -24,19 +24,35 @@ const REPORT_SEVERITY: Record<ReportReason, ReportSeverity> = {
OTHER: "LOW",
};
const SEVERITY_RANK: Record<ReportSeverity, number> = { 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<ReportSeverity, ReportReason[]>;
/** 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(
+3 -19
View File
@@ -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<string, { name: string | null; email: string | null }>();
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,
}));
}),
+11 -25
View File
@@ -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 };
}),
@@ -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.