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:
1 parent
de6b36e3d3
commit
5cf3deb006
13 files changed
+192
-121
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 } })
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user