From 77cdebec0f23552262fae4ccb771bf0eca89428b Mon Sep 17 00:00:00 2001
From: neodk2004
Date: Wed, 7 Oct 2026 07:16:08 +0200
Subject: [PATCH] =?UTF-8?q?feat(admin):=20Melde-Statistik=20=E2=80=94=20we?=
=?UTF-8?q?r=20oft=20gemeldet=20wird=20und=20wer=20oft=20falsch=20meldet?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Neue Tabelle ReportDecision (nur anhängen, ohne Fremdschlüssel): jede
Moderationsentscheidung zu einer Meldung (bestätigt/abgewiesen) mit
Melder und gemeldeter Seite als Momentaufnahme.
- Geschrieben von dismissReport, deletePostAsModerator, dem neuen
upholdReport (Profil-Meldungen bestätigen) und petAdmin.delete.
- Auswertung: Abzeichen und Zahlen in der Nutzerliste, im Tier-Detail und
in der Warteschlange („Melder: häufige Fehlmeldungen“), Tab „Auswertung“
auf der Meldungen-Seite mit Ranglisten.
- Datenschutz: Tier gelöscht → Tier-/Beitrags-IDs anonymisiert, Konto
gelöscht → Owner-IDs anonymisiert, Zählung der Gegenseite bleibt. Teil der
Art.-15-Auskunft (ohne Gegenseite), Abschnitt 4e in der
Datenschutzerklärung.
Braucht vor dem Deploy: prisma db push + prisma/enable_rls_report_decisions.sql.
Co-Authored-By: Claude Opus 5.5
---
CLAUDE.md | 2 +
prisma/enable_rls_report_decisions.sql | 4 +
prisma/schema.prisma | 33 ++
src/__tests__/account-data.test.ts | 43 +++
src/__tests__/admin-report-feedback.test.ts | 92 +++++
src/__tests__/delete-pet.test.ts | 18 +
src/__tests__/helpers/prisma-mock.ts | 15 +-
src/__tests__/pet-admin.test.ts | 26 ++
src/__tests__/report-decisions.test.ts | 184 +++++++++
src/__tests__/report-stats-admin.test.ts | 41 ++
src/app/datenschutz/page.tsx | 30 +-
src/app/p/[secret]/reports/page.tsx | 351 ++++++++++--------
src/app/p/[secret]/users/page.tsx | 10 +
src/components/admin/ReportStats.tsx | 52 +++
src/components/admin/pets/PetDetailSheet.tsx | 6 +
.../admin/reports/ReportStatsTab.tsx | 84 +++++
src/lib/delete-account.ts | 5 +
src/lib/delete-pet.ts | 4 +
src/lib/gdpr-export.ts | 29 +-
src/lib/report-decisions.ts | 201 ++++++++++
src/lib/report-feedback.ts | 10 +
src/trpc/routers/_app.ts | 2 +
src/trpc/routers/admin.ts | 95 +++--
src/trpc/routers/pet-admin.ts | 14 +
src/trpc/routers/report-stats-admin.ts | 39 ++
25 files changed, 1213 insertions(+), 177 deletions(-)
create mode 100644 prisma/enable_rls_report_decisions.sql
create mode 100644 src/__tests__/report-decisions.test.ts
create mode 100644 src/__tests__/report-stats-admin.test.ts
create mode 100644 src/components/admin/ReportStats.tsx
create mode 100644 src/components/admin/reports/ReportStatsTab.tsx
create mode 100644 src/lib/report-decisions.ts
create mode 100644 src/trpc/routers/report-stats-admin.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 6ff80d9..b864f36 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -86,6 +86,8 @@ All uploads are presigned/direct-to-storage — media bytes never pass through t
Gated by path secret (see `proxy.ts` above) *and* by role at the tRPC layer: `src/trpc/routers/admin.ts`'s `assertAdmin()` allows either `ADMIN_OWNER_ID` (env-configured super-admin bootstrap) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Actions are written to `ModerationLog`. Pages live under `src/app/p/[secret]/{users,pets,moderators,posts,reports,ads,referrals,curator,…}`. The dashboard has a collapsible "Tierarten" card (`SpeciesDistributionCard`, `petAdmin.speciesDistribution`: pets per species incl. empty species and per breed, curator pet excluded). The "Tiere" page (`pets`, router `pet-admin.ts`) manages single pets: verified/founding toggles, clearing profile content, deleting a pet (reason is sent to the owner as a team DM on another of their pets, or by e-mail if it was their only pet) and the owner's referral ranks.
+**Report statistics (`ReportDecision`, `src/lib/report-decisions.ts`):** every moderator decision on a report is stored as an append-only row (UPHELD/DISMISSED) before the `Report` itself is deleted — shown on the users page, the pet detail sheet and the "Auswertung" tab of `/p/[secret]/reports`. A new path that resolves reports by moderator decision must run `reportDecisionOps()` in the same transaction (current: `dismissReport`, `upholdReport`, `deletePostAsModerator` in `admin.ts`, `petAdmin.delete`). Rows have no FKs: `deletePetCompletely` nulls the pet's ids, `deleteOwnerAccount` anonymizes the owner's ids — never delete the rows, other owners' counts depend on them.
+
**Never delete a pet with a bare `prisma.pet.delete`** — use `deletePetCompletely()` (`src/lib/delete-pet.ts`). `StoryView` (D-08), `Report.reporter` and `Repost.originalPost` have no cascade and would block the delete, and the denormalized counters on *other* pets' posts/stories must drop by this pet's paws/comments/paw-backs. `pets.delete`, `deleteOwnerAccount` and the admin delete all go through it.
### Download redirect `/app`
diff --git a/prisma/enable_rls_report_decisions.sql b/prisma/enable_rls_report_decisions.sql
new file mode 100644
index 0000000..7448ac5
--- /dev/null
+++ b/prisma/enable_rls_report_decisions.sql
@@ -0,0 +1,4 @@
+-- Melde-Statistik (ReportDecision): neue Tabelle für den PostgREST-Zugriff
+-- sperren, wie alle anderen Tabellen (siehe enable_rls.sql).
+-- Nach dem prisma db push einmal ausführen.
+ALTER TABLE public."ReportDecision" ENABLE ROW LEVEL SECURITY;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 6e9de6f..5bb6af5 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -414,6 +414,12 @@ enum ReportReason {
OTHER
}
+/// Ausgang einer Moderationsentscheidung zu einer Meldung (ReportDecision).
+enum ReportOutcome {
+ UPHELD // bestätigt: Beitrag gelöscht, Tier gelöscht oder Profil-Meldung bestätigt
+ DISMISSED // abgewiesen
+}
+
// ─── Phase 2 Models ──────────────────────────────────────────────────────────
model Post {
@@ -665,6 +671,33 @@ model Report {
@@index([targetPostId])
}
+/// Melde-Statistik: eine Zeile pro entschiedener Meldung, nur anhängen, nie
+/// ändern — außer Anonymisierung (src/lib/report-decisions.ts). Bewusst ohne
+/// Fremdschlüssel, damit Zeilen gelöschte Beiträge/Tiere überleben: Beim
+/// Löschen eines Tiers fallen seine Tier-/Beitrags-IDs weg, beim Löschen des
+/// Kontos die Owner-IDs — die Zählung der jeweils anderen Seite bleibt gleich.
+model ReportDecision {
+ id String @id @default(cuid())
+ reporterPetId String?
+ reporterOwnerId String?
+ /// Gemeldetes Tier bzw. das Tier, dem der gemeldete Beitrag gehörte (Momentaufnahme).
+ targetPetId String?
+ targetOwnerId String?
+ /// null bei Profil-Meldungen und nach Anonymisierung.
+ targetPostId String?
+ isProfileReport Boolean
+ reason ReportReason
+ outcome ReportOutcome
+ reportedAt DateTime
+ decidedAt DateTime @default(now())
+ decidedBy String
+
+ @@index([reporterPetId])
+ @@index([reporterOwnerId])
+ @@index([targetPetId])
+ @@index([targetOwnerId])
+}
+
// ─── Phase 3 Models ──────────────────────────────────────────────────────────
model Reaction {
diff --git a/src/__tests__/account-data.test.ts b/src/__tests__/account-data.test.ts
index a2e7261..2e4c6ab 100644
--- a/src/__tests__/account-data.test.ts
+++ b/src/__tests__/account-data.test.ts
@@ -43,6 +43,23 @@ describe("deleteOwnerAccount", () => {
});
expect(mockDeleteUser).toHaveBeenCalledWith("owner-gone");
});
+
+ it("anonymisiert die Melde-Statistik, statt sie zu löschen (Zählung der Gegenseite bleibt)", async () => {
+ const prisma = createMockPrisma();
+ prisma.owner.delete.mockResolvedValue({});
+
+ await deleteOwnerAccount(asDb(prisma), "owner-gone");
+
+ expect(prisma.reportDecision.updateMany).toHaveBeenCalledWith({
+ where: { reporterOwnerId: "owner-gone" },
+ data: { reporterOwnerId: null },
+ });
+ expect(prisma.reportDecision.updateMany).toHaveBeenCalledWith({
+ where: { targetOwnerId: "owner-gone" },
+ data: { targetOwnerId: null },
+ });
+ expect(prisma.reportDecision.deleteMany).not.toHaveBeenCalled();
+ });
});
describe("buildDataExport — referral program", () => {
@@ -88,4 +105,30 @@ describe("buildDataExport — referral program", () => {
});
expect(JSON.stringify(data)).not.toContain("user_inviter_secret_id");
});
+
+ it("enthält die Melde-Statistik zur Person — ohne die Gegenseite oder den Moderator zu nennen", async () => {
+ const prisma = createMockPrisma();
+ prisma.owner.findUnique.mockResolvedValue({
+ id: "owner-1", createdAt: new Date("2026-09-01"), invitedById: null, referralCode: "LUNA-7K3M",
+ referralPerks: [], pets: [], rankAchievements: [], issuedInvites: [],
+ });
+ prisma.owner.findMany.mockResolvedValue([]);
+ const at = new Date("2026-10-01");
+ prisma.reportDecision.findMany.mockResolvedValue([
+ { reporterOwnerId: "owner-1", targetOwnerId: "other-owner", reason: "SPAM", outcome: "DISMISSED", isProfileReport: false, reportedAt: at, decidedAt: at },
+ { reporterOwnerId: "someone", targetOwnerId: "owner-1", reason: "HARASSMENT", outcome: "UPHELD", isProfileReport: true, reportedAt: at, decidedAt: at },
+ ]);
+
+ const data = await buildDataExport(asDb(prisma), "owner-1");
+
+ expect(prisma.reportDecision.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { OR: [{ reporterOwnerId: "owner-1" }, { targetOwnerId: "owner-1" }] } })
+ );
+ expect(data?.moderation).toEqual({
+ reportsFiledDecisions: [{ reason: "SPAM", outcome: "DISMISSED", isProfileReport: false, reportedAt: at, decidedAt: at }],
+ reportsAboutYouDecisions: [{ reason: "HARASSMENT", outcome: "UPHELD", isProfileReport: true, reportedAt: at, decidedAt: at }],
+ });
+ expect(JSON.stringify(data?.moderation)).not.toContain("other-owner");
+ expect(JSON.stringify(data?.moderation)).not.toContain("someone");
+ });
});
diff --git a/src/__tests__/admin-report-feedback.test.ts b/src/__tests__/admin-report-feedback.test.ts
index 4f9c899..213a334 100644
--- a/src/__tests__/admin-report-feedback.test.ts
+++ b/src/__tests__/admin-report-feedback.test.ts
@@ -151,3 +151,95 @@ describe("admin.deleteReportedPost reporter feedback", () => {
expect(mockSendTeamMessage).not.toHaveBeenCalled();
});
});
+
+// Melde-Statistik (ReportDecision) — jede Entscheidung wird festgehalten.
+describe("Melde-Statistik: Entscheidungen werden festgehalten", () => {
+ const reportedAt = new Date("2026-10-01T10:00:00Z");
+
+ it("dismissReport schreibt eine abgewiesene Entscheidung in derselben Transaktion", async () => {
+ vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
+ const { caller, mockPrisma } = await createAdminCaller();
+ mockPrisma.report.findUnique.mockResolvedValue({
+ id: "r1", reporterPetId: "reporter-1", targetPostId: null, targetPetId: "pet-9", reason: "SPAM", createdAt: reportedAt,
+ });
+ mockPrisma.pet.findMany.mockResolvedValue([
+ { id: "reporter-1", ownerId: "owner-r" },
+ { id: "pet-9", ownerId: "owner-t" },
+ ]);
+
+ await caller.admin.dismissReport({ reportId: "r1", notifyReporter: false });
+
+ expect(mockPrisma.reportDecision.createMany).toHaveBeenCalledWith({
+ data: [expect.objectContaining({
+ reporterOwnerId: "owner-r", targetPetId: "pet-9", targetOwnerId: "owner-t", isProfileReport: true,
+ outcome: "DISMISSED", decidedBy: "admin-1",
+ })],
+ });
+ });
+
+ it("deleteReportedPost schreibt für jede offene Meldung eine bestätigte Entscheidung", async () => {
+ vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
+ const { caller, mockPrisma } = await createAdminCaller();
+ mockPrisma.post.findUnique.mockResolvedValue({ repost: null, pet: { name: "Bubi" } });
+ mockPrisma.report.findMany.mockResolvedValue([
+ { reporterPetId: "reporter-1", targetPostId: "post-1", targetPetId: null, reason: "SPAM", createdAt: reportedAt },
+ { reporterPetId: "reporter-2", targetPostId: "post-1", targetPetId: null, reason: "OTHER", createdAt: reportedAt },
+ ]);
+ mockPrisma.post.findMany.mockResolvedValue([{ id: "post-1", petId: "bubi", pet: { ownerId: "owner-b" } }]);
+
+ await caller.admin.deleteReportedPost({ postId: "post-1", notifyReporters: false });
+
+ const { data } = mockPrisma.reportDecision.createMany.mock.calls[0][0];
+ expect(data).toHaveLength(2);
+ expect(data[0]).toMatchObject({ targetPetId: "bubi", targetOwnerId: "owner-b", outcome: "UPHELD" });
+ });
+
+ it("deletePost (Beitragsliste) zählt offene Meldungen ebenfalls als bestätigt", async () => {
+ vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
+ const { caller, mockPrisma } = await createAdminCaller();
+ mockPrisma.post.findUnique.mockResolvedValue({ repost: null, pet: { name: "Bubi" } });
+ mockPrisma.report.findMany.mockResolvedValue([
+ { reporterPetId: "reporter-1", targetPostId: "post-1", targetPetId: null, reason: "SPAM", createdAt: reportedAt },
+ ]);
+
+ await caller.admin.deletePost({ postId: "post-1" });
+
+ expect(mockPrisma.reportDecision.createMany).toHaveBeenCalledWith({
+ data: [expect.objectContaining({ outcome: "UPHELD" })],
+ });
+ expect(mockSendTeamMessage).not.toHaveBeenCalled();
+ });
+
+ it("upholdReport bestätigt eine Profil-Meldung, löscht sie und dankt dem Melder", async () => {
+ vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
+ const { caller, mockPrisma } = await createAdminCaller();
+ mockPrisma.report.findUnique.mockResolvedValue({
+ id: "r2", reporterPetId: "reporter-1", targetPostId: null, targetPetId: "pet-9", reason: "HARASSMENT", createdAt: reportedAt,
+ });
+ mockPrisma.pet.findUnique.mockResolvedValue({ name: "Luna" });
+ mockSendTeamMessage.mockResolvedValue(undefined);
+
+ await expect(caller.admin.upholdReport({ reportId: "r2" })).resolves.toEqual({ success: true });
+
+ expect(mockPrisma.report.delete).toHaveBeenCalledWith({ where: { id: "r2" } });
+ expect(mockPrisma.reportDecision.createMany).toHaveBeenCalledWith({
+ data: [expect.objectContaining({ outcome: "UPHELD", isProfileReport: true })],
+ });
+ expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({ action: "UPHOLD_REPORT", targetType: "REPORT", targetId: "r2" }),
+ });
+ const body = mockSendTeamMessage.mock.calls[0][2] as string;
+ expect(body).toContain("Meldung zum Profil von Luna");
+ });
+
+ it("upholdReport lehnt Beitrags-Meldungen ab — die werden über „Beitrag löschen“ bestätigt", async () => {
+ vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
+ const { caller, mockPrisma } = await createAdminCaller();
+ mockPrisma.report.findUnique.mockResolvedValue({
+ id: "r1", reporterPetId: "reporter-1", targetPostId: "post-1", targetPetId: null, reason: "SPAM", createdAt: reportedAt,
+ });
+
+ await expect(caller.admin.upholdReport({ reportId: "r1" })).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ expect(mockPrisma.report.delete).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/delete-pet.test.ts b/src/__tests__/delete-pet.test.ts
index c133e8c..7b65d87 100644
--- a/src/__tests__/delete-pet.test.ts
+++ b/src/__tests__/delete-pet.test.ts
@@ -57,6 +57,24 @@ describe("deletePetCompletely", () => {
expect(prisma.pet.delete.mock.invocationCallOrder[0]).toBeGreaterThan(lastRaw);
});
+ it("anonymisiert die Tier- und Beitrags-IDs in der Melde-Statistik, vor dem Löschen des Tiers", async () => {
+ const prisma = setup();
+
+ await deletePetCompletely(asDb(prisma), "pet-1");
+
+ expect(prisma.reportDecision.updateMany).toHaveBeenCalledWith({
+ where: { reporterPetId: "pet-1" },
+ data: { reporterPetId: null },
+ });
+ expect(prisma.reportDecision.updateMany).toHaveBeenCalledWith({
+ where: { targetPetId: "pet-1" },
+ data: { targetPetId: null, targetPostId: null },
+ });
+ expect(prisma.pet.delete.mock.invocationCallOrder[0]).toBeGreaterThan(
+ Math.max(...prisma.reportDecision.updateMany.mock.invocationCallOrder)
+ );
+ });
+
it("removes avatar, post images, story images and Mux assets", async () => {
const prisma = setup();
await deletePetCompletely(asDb(prisma), "pet-1");
diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts
index 7741cea..dd22362 100644
--- a/src/__tests__/helpers/prisma-mock.ts
+++ b/src/__tests__/helpers/prisma-mock.ts
@@ -391,6 +391,7 @@ interface MockPrismaClient {
userBan: MockUserBanClient;
shadowban: MockShadowbanClient;
warning: { create: MockFn; findMany: MockFn };
+ reportDecision: { createMany: MockFn; updateMany: MockFn; deleteMany: MockFn; groupBy: MockFn; findMany: MockFn };
blacklistEntry: MockBlacklistEntryClient;
// Phase 3
reaction: MockReactionClient;
@@ -433,7 +434,7 @@ export function createMockPrisma(): MockPrismaClient {
return {
pet: {
create: vi.fn(),
- findMany: vi.fn(),
+ findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
@@ -483,7 +484,7 @@ export function createMockPrisma(): MockPrismaClient {
// Phase 2 mocks
post: {
create: vi.fn(),
- findMany: vi.fn(),
+ findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
@@ -587,7 +588,7 @@ export function createMockPrisma(): MockPrismaClient {
report: {
create: vi.fn(),
findFirst: vi.fn().mockResolvedValue(null),
- findMany: vi.fn(),
+ findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
@@ -607,6 +608,14 @@ export function createMockPrisma(): MockPrismaClient {
create: vi.fn(),
findMany: vi.fn().mockResolvedValue([]),
},
+ // Melde-Statistik (report-decisions.ts) — Standard: keine Einträge.
+ reportDecision: {
+ createMany: vi.fn().mockResolvedValue({ count: 0 }),
+ updateMany: vi.fn().mockResolvedValue({ count: 0 }),
+ deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
+ groupBy: vi.fn().mockResolvedValue([]),
+ findMany: vi.fn().mockResolvedValue([]),
+ },
blacklistEntry: {
findMany: vi.fn().mockResolvedValue([]),
upsert: vi.fn(),
diff --git a/src/__tests__/pet-admin.test.ts b/src/__tests__/pet-admin.test.ts
index 5162f54..d6c7536 100644
--- a/src/__tests__/pet-admin.test.ts
+++ b/src/__tests__/pet-admin.test.ts
@@ -74,6 +74,32 @@ describe("petAdmin.delete", () => {
});
});
+ it("zählt offene Meldungen zum Tier und seinen Beiträgen als bestätigt, bevor es gelöscht wird", async () => {
+ const prisma = createMockPrisma();
+ prisma.pet.findUnique.mockResolvedValue({ name: "Luna", ownerId: "owner-1" });
+ prisma.pet.findFirst.mockResolvedValue({ id: "pet-other" });
+ prisma.moderationLog.create.mockResolvedValue({});
+ prisma.$transaction.mockImplementation((arg: unknown) => Promise.all(arg as unknown[]));
+ const reportedAt = new Date("2026-10-01T10:00:00Z");
+ prisma.report.findMany.mockResolvedValue([
+ { reporterPetId: "r-1", targetPostId: null, targetPetId: "pet-luna", reason: "HARASSMENT", createdAt: reportedAt },
+ { reporterPetId: "r-2", targetPostId: "post-luna", targetPetId: null, reason: "SPAM", createdAt: reportedAt },
+ ]);
+ const { c } = await createCaller(prisma);
+
+ await c.petAdmin.delete({ petId: "pet-luna", reason: "Fake-Profil", notifyOwner: false });
+
+ expect(prisma.report.findMany).toHaveBeenCalledWith({
+ where: { OR: [{ targetPetId: "pet-luna" }, { targetPostId: { in: [] } }] },
+ select: { reporterPetId: true, targetPostId: true, targetPetId: true, reason: true, createdAt: true },
+ });
+ const { data } = prisma.reportDecision.createMany.mock.calls[0][0];
+ expect(data.map((d: { outcome: string }) => d.outcome)).toEqual(["UPHELD", "UPHELD"]);
+ expect(prisma.reportDecision.createMany.mock.invocationCallOrder[0]).toBeLessThan(
+ mockDeletePet.mock.invocationCallOrder.at(-1)!
+ );
+ });
+
it("falls back to e-mail when it was the owner's only pet", async () => {
const prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue({ name: "Luna", ownerId: "owner-1" });
diff --git a/src/__tests__/report-decisions.test.ts b/src/__tests__/report-decisions.test.ts
new file mode 100644
index 0000000..b0930b8
--- /dev/null
+++ b/src/__tests__/report-decisions.test.ts
@@ -0,0 +1,184 @@
+/**
+ * Melde-Statistik (ReportDecision): Entscheidungen festhalten, anonymisieren,
+ * auswerten. Admin-Review 2026-10-07.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("server-only", () => ({}));
+
+import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
+import {
+ reportDecisionOps,
+ anonymizeOwnerInReportDecisions,
+ forgetPetInReportDecisions,
+ reportStatsByOwner,
+ isFrequentFalseReporter,
+ emptyCounts,
+ reportRankings,
+} from "@/lib/report-decisions";
+
+type MockPrisma = ReturnType;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const asClient = (p: MockPrisma) => p as any;
+
+const reportedAt = new Date("2026-10-01T10:00:00Z");
+
+describe("reportDecisionOps", () => {
+ let prisma: MockPrisma;
+ beforeEach(() => {
+ prisma = createMockPrisma();
+ prisma.reportDecision.createMany.mockReturnValue("create-op");
+ });
+
+ it("hält Melder und gemeldete Seite (Tier + Halter) als Momentaufnahme fest", async () => {
+ prisma.post.findMany.mockResolvedValue([{ id: "post-1", petId: "author-pet", pet: { ownerId: "author-owner" } }]);
+ prisma.pet.findMany.mockResolvedValue([
+ { id: "reporter-1", ownerId: "reporter-owner-1" },
+ { id: "reporter-2", ownerId: "reporter-owner-2" },
+ { id: "target-pet", ownerId: "target-owner" },
+ ]);
+
+ const ops = await reportDecisionOps(
+ asClient(prisma),
+ [
+ { reporterPetId: "reporter-1", targetPostId: "post-1", targetPetId: null, reason: "SPAM", createdAt: reportedAt },
+ { reporterPetId: "reporter-2", targetPostId: null, targetPetId: "target-pet", reason: "HARASSMENT", createdAt: reportedAt },
+ ],
+ "UPHELD",
+ "admin-1"
+ );
+
+ expect(ops).toEqual(["create-op"]);
+ expect(prisma.reportDecision.createMany).toHaveBeenCalledWith({
+ data: [
+ {
+ reporterPetId: "reporter-1",
+ reporterOwnerId: "reporter-owner-1",
+ targetPetId: "author-pet",
+ targetOwnerId: "author-owner",
+ targetPostId: "post-1",
+ isProfileReport: false,
+ reason: "SPAM",
+ outcome: "UPHELD",
+ reportedAt,
+ decidedBy: "admin-1",
+ },
+ {
+ reporterPetId: "reporter-2",
+ reporterOwnerId: "reporter-owner-2",
+ targetPetId: "target-pet",
+ targetOwnerId: "target-owner",
+ targetPostId: null,
+ isProfileReport: true,
+ reason: "HARASSMENT",
+ outcome: "UPHELD",
+ reportedAt,
+ decidedBy: "admin-1",
+ },
+ ],
+ });
+ });
+
+ it("ohne Meldungen gibt es keinen Schritt", async () => {
+ expect(await reportDecisionOps(asClient(prisma), [], "DISMISSED", "admin-1")).toEqual([]);
+ expect(prisma.reportDecision.createMany).not.toHaveBeenCalled();
+ });
+});
+
+describe("Anonymisierung", () => {
+ it("Konto gelöscht: Owner-IDs fallen weg, die Zeilen (und damit die Zählung der Gegenseite) bleiben", async () => {
+ const prisma = createMockPrisma();
+ await anonymizeOwnerInReportDecisions(asClient(prisma), "owner-1");
+
+ expect(prisma.reportDecision.updateMany).toHaveBeenCalledWith({
+ where: { reporterOwnerId: "owner-1" },
+ data: { reporterOwnerId: null },
+ });
+ expect(prisma.reportDecision.updateMany).toHaveBeenCalledWith({
+ where: { targetOwnerId: "owner-1" },
+ data: { targetOwnerId: null },
+ });
+ expect(prisma.reportDecision.deleteMany).not.toHaveBeenCalled();
+ });
+
+ it("Tier gelöscht: Tier- und Beitrags-IDs fallen weg", async () => {
+ const prisma = createMockPrisma();
+ await forgetPetInReportDecisions(asClient(prisma), "pet-1");
+
+ expect(prisma.reportDecision.updateMany).toHaveBeenCalledWith({
+ where: { reporterPetId: "pet-1" },
+ data: { reporterPetId: null },
+ });
+ expect(prisma.reportDecision.updateMany).toHaveBeenCalledWith({
+ where: { targetPetId: "pet-1" },
+ data: { targetPetId: null, targetPostId: null },
+ });
+ });
+});
+
+describe("Auswertung", () => {
+ it("zählt pro Halter erhaltene und abgegebene Meldungen nach Ergebnis", async () => {
+ const prisma = createMockPrisma();
+ prisma.reportDecision.groupBy
+ .mockResolvedValueOnce([
+ { targetOwnerId: "owner-1", outcome: "UPHELD", _count: 4 },
+ { targetOwnerId: "owner-1", outcome: "DISMISSED", _count: 3 },
+ ])
+ .mockResolvedValueOnce([{ reporterOwnerId: "owner-1", outcome: "DISMISSED", _count: 11 }]);
+
+ const stats = await reportStatsByOwner(asClient(prisma), ["owner-1", "owner-2"]);
+
+ expect(stats.get("owner-1")).toEqual({
+ received: { upheld: 4, dismissed: 3 },
+ filed: { upheld: 0, dismissed: 11 },
+ });
+ expect(stats.get("owner-2")).toEqual({ received: emptyCounts(), filed: emptyCounts() });
+ });
+
+ it("ohne IDs keine Abfrage", async () => {
+ const prisma = createMockPrisma();
+ expect((await reportStatsByOwner(asClient(prisma), [])).size).toBe(0);
+ expect(prisma.reportDecision.groupBy).not.toHaveBeenCalled();
+ });
+
+ it("häufige Fehlmeldungen erst ab 5 entschiedenen Meldungen und unter 20 % bestätigt", () => {
+ expect(isFrequentFalseReporter({ upheld: 0, dismissed: 4 })).toBe(false);
+ expect(isFrequentFalseReporter({ upheld: 0, dismissed: 5 })).toBe(true);
+ expect(isFrequentFalseReporter({ upheld: 1, dismissed: 4 })).toBe(false); // 20 % → nicht darunter
+ expect(isFrequentFalseReporter({ upheld: 1, dismissed: 9 })).toBe(true); // 10 %
+ });
+});
+
+describe("reportRankings", () => {
+ it("listet am häufigsten bestätigt gemeldete Tiere und Tiere mit häufigen Fehlmeldungen", async () => {
+ const prisma = createMockPrisma();
+ prisma.reportDecision.groupBy
+ // gemeldet (targetPetId × outcome)
+ .mockResolvedValueOnce([
+ { targetPetId: "bad", outcome: "UPHELD", _count: 5 },
+ { targetPetId: "bad", outcome: "DISMISSED", _count: 1 },
+ { targetPetId: "meh", outcome: "UPHELD", _count: 2 },
+ { targetPetId: "clean", outcome: "DISMISSED", _count: 9 },
+ ])
+ // gemeldet von (reporterPetId × outcome)
+ .mockResolvedValueOnce([
+ { reporterPetId: "troll", outcome: "DISMISSED", _count: 8 },
+ { reporterPetId: "good", outcome: "UPHELD", _count: 6 },
+ { reporterPetId: "new", outcome: "DISMISSED", _count: 2 },
+ ])
+ // Summen
+ .mockResolvedValueOnce([
+ { outcome: "UPHELD", _count: 13 },
+ { outcome: "DISMISSED", _count: 20 },
+ ]);
+
+ const r = await reportRankings(asClient(prisma), 10);
+
+ expect(r.totals).toEqual({ upheld: 13, dismissed: 20 });
+ expect(r.mostReported).toEqual([
+ { petId: "bad", counts: { upheld: 5, dismissed: 1 } },
+ { petId: "meh", counts: { upheld: 2, dismissed: 0 } },
+ ]);
+ expect(r.falseReporters).toEqual([{ petId: "troll", counts: { upheld: 0, dismissed: 8 } }]);
+ });
+});
diff --git a/src/__tests__/report-stats-admin.test.ts b/src/__tests__/report-stats-admin.test.ts
new file mode 100644
index 0000000..a3f8518
--- /dev/null
+++ b/src/__tests__/report-stats-admin.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect, vi } from "vitest";
+
+vi.mock("server-only", () => ({}));
+vi.mock("@/lib/prisma", () => ({ prisma: {} }));
+const mockAssertAdmin = vi.fn();
+vi.mock("@/trpc/routers/admin", () => ({ assertAdmin: (...a: unknown[]) => mockAssertAdmin(...a) }));
+
+import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
+
+async function createCaller(prisma: ReturnType) {
+ const { reportStatsAdminRouter } = await import("@/trpc/routers/report-stats-admin");
+ const { initTRPC } = await import("@trpc/server");
+ const t = initTRPC.context<{ userId: string; prisma: typeof prisma }>().create();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return t.router({ reportStatsAdmin: reportStatsAdminRouter }).createCaller({ userId: "admin-1", prisma: prisma as any });
+}
+
+describe("reportStatsAdmin.overview", () => {
+ it("braucht MODERATOR und liefert Ranglisten mit Tier — gelöschte Tiere fallen heraus", async () => {
+ const prisma = createMockPrisma();
+ prisma.reportDecision.groupBy
+ .mockResolvedValueOnce([
+ { targetPetId: "bad", outcome: "UPHELD", _count: 3 },
+ { targetPetId: "gone", outcome: "UPHELD", _count: 2 },
+ ])
+ .mockResolvedValueOnce([{ reporterPetId: "troll", outcome: "DISMISSED", _count: 6 }])
+ .mockResolvedValueOnce([{ outcome: "UPHELD", _count: 5 }, { outcome: "DISMISSED", _count: 6 }]);
+ prisma.pet.findMany.mockResolvedValue([
+ { id: "bad", name: "Rex" },
+ { id: "troll", name: "Mimi" },
+ ]);
+ const caller = await createCaller(prisma);
+
+ const res = await caller.reportStatsAdmin.overview();
+
+ expect(mockAssertAdmin).toHaveBeenCalledWith(expect.anything(), "admin-1", "MODERATOR");
+ expect(res.totals).toEqual({ upheld: 5, dismissed: 6 });
+ expect(res.mostReported).toEqual([{ pet: { id: "bad", name: "Rex" }, upheld: 3, dismissed: 0 }]);
+ expect(res.falseReporters).toEqual([{ pet: { id: "troll", name: "Mimi" }, upheld: 0, dismissed: 6 }]);
+ });
+});
diff --git a/src/app/datenschutz/page.tsx b/src/app/datenschutz/page.tsx
index 2ef19cf..5a2e437 100644
--- a/src/app/datenschutz/page.tsx
+++ b/src/app/datenschutz/page.tsx
@@ -278,6 +278,30 @@ export default function DatenschutzPage() {
+
+
+ Sie können Beiträge und Profile melden. Unsere Moderation prüft jede Meldung und entscheidet,
+ ob sie begründet ist (z. B. Beitrag entfernt) oder abgewiesen wird. Die Meldung selbst
+ löschen wir nach der Entscheidung. Zu jeder Entscheidung speichern wir einen Eintrag mit dem
+ meldenden und dem gemeldeten Tier bzw. Konto, dem Meldegrund, dem Ergebnis und dem Zeitpunkt.
+
+
+ Daraus sieht unsere Moderation, wie oft Inhalte eines Kontos begründet gemeldet wurden und
+ ob ein Konto häufig unbegründete Meldungen abgibt. Zweck ist eine sichere Plattform und der
+ Schutz vor Missbrauch des Meldesystems; Rechtsgrundlage ist unser berechtigtes Interesse
+ (Art. 6 Abs. 1 lit. f DSGVO) sowie die Pflicht, gegen missbräuchliche Meldungen vorzugehen
+ (Art. 6 Abs. 1 lit. c DSGVO i. V. m. Art. 23 Digital Services Act). Die Einträge
+ sehen nur Moderatoren. Wer gemeldet hat, erfährt die gemeldete Person nicht.
+
+
+ Wird ein Tierprofil gelöscht, entfernen wir den Bezug der Einträge zu diesem Tier und seinen
+ Beiträgen. Wird Ihr Konto gelöscht, anonymisieren wir die Einträge: Der Bezug zu Ihnen
+ entfällt, die Entscheidung selbst bleibt ohne Personenbezug für die Statistik der anderen
+ Beteiligten erhalten. In Ihrer Datenauskunft (Art. 15 DSGVO) sind die Entscheidungen zu
+ Ihren Meldungen und zu Meldungen über Sie enthalten, ohne die Gegenseite zu nennen.
+
+
+
Wir verwenden technisch notwendige Cookies zur Sitzungsverwaltung. Tracking-Cookies oder
@@ -307,6 +331,10 @@ export default function DatenschutzPage() {
meldet, und mit Ihrem Konto. Kontaktdaten einer Vermisst-Meldung löschen wir, sobald die
Meldung beendet ist (siehe Abschnitte 4c und 4d).
+
+ Einträge der Melde-Statistik bewahren wir auf, solange Ihr Konto besteht; bei der Löschung
+ des Kontos werden sie anonymisiert (siehe Abschnitt 4e).
+
Technische Fehlerberichte (Absturz-/Fehlerdaten inkl. IP-Adresse, siehe Abschnitt 2)
werden automatisiert nach 30 Tagen gelöscht. Dieser Zeitraum ist nach unserer Erfahrung
@@ -359,7 +387,7 @@ export default function DatenschutzPage() {
- Stand: September 2026. Durch die Weiterentwicklung unserer Plattform kann es notwendig
+ Stand: Oktober 2026. Durch die Weiterentwicklung unserer Plattform kann es notwendig
werden, diese Erklärung zu ändern. Die jeweils aktuelle Fassung ist stets auf dieser
Seite abrufbar.