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.

diff --git a/src/app/p/[secret]/reports/page.tsx b/src/app/p/[secret]/reports/page.tsx index c8bed69..b1a0e7b 100644 --- a/src/app/p/[secret]/reports/page.tsx +++ b/src/app/p/[secret]/reports/page.tsx @@ -5,12 +5,14 @@ import { useQuery, useMutation } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { getMediaUrl } from "@/lib/media-url"; import { BlurImage } from "@/components/ui/blur-image"; -import { Trash2, X, Loader2, CheckCheck, MessageCircle, PawPrint, ImageOff, UserRound } from "lucide-react"; +import { Trash2, X, Loader2, CheckCheck, MessageCircle, PawPrint, ImageOff, UserRound, ShieldCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { AdminPageHeader } from "@/components/admin/AdminSection"; +import { ReportStatsTab } from "@/components/admin/reports/ReportStatsTab"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { StatusBadge, type StatusTone } from "@/components/admin/StatusBadge"; import { formatEventTime } from "@/lib/admin/format"; import { formatPetName } from "@/lib/pet-display-name"; @@ -31,8 +33,31 @@ const SEVERITY_TONE: Record = { HIGH: "danger", MEDIUM: "war type ReportAction = | { kind: "dismiss"; reportId: string } + | { kind: "uphold"; reportId: string } | { kind: "deletePost"; postId: string }; +// Texte des Bestätigungsdialogs je Aktion. +const ACTION_COPY: Record = { + dismiss: { + title: "Meldung abweisen", + confirm: "Abweisen", + notify: "Melder benachrichtigen", + notifyHint: "Team-Nachricht: Meldung geprüft, kein Verstoß — plus deine Begründung.", + }, + uphold: { + title: "Meldung bestätigen", + confirm: "Bestätigen", + notify: "Melder danken", + notifyHint: "Team-Nachricht: Meldung geprüft, Maßnahmen ergriffen, danke fürs Melden.", + }, + deletePost: { + title: "Beitrag löschen", + confirm: "Beitrag löschen", + notify: "Allen Meldern danken", + notifyHint: "Team-Nachricht: Meldung geprüft, Inhalt entfernt, danke fürs Melden.", + }, +}; + export default function AdminReportsPage() { const trpc = useTRPC(); const [action, setAction] = useState(null); @@ -63,6 +88,12 @@ export default function AdminReportsPage() { }) ); + const uphold = useMutation( + trpc.admin.upholdReport.mutationOptions({ + onSuccess: () => { closeDialog(); }, + }) + ); + const deletePost = useMutation( trpc.admin.deleteReportedPost.mutationOptions({ onSuccess: () => { closeDialog(); }, @@ -84,12 +115,15 @@ export default function AdminReportsPage() { notifyReporter, message: dismissMessage.trim() || undefined, }); + } else if (action.kind === "uphold") { + uphold.mutate({ reportId: action.reportId, notifyReporter }); } else { deletePost.mutate({ postId: action.postId, reason: reason || undefined, notifyReporters: notifyReporter }); } } - const isPending = dismiss.isPending || deletePost.isPending; + const isPending = dismiss.isPending || uphold.isPending || deletePost.isPending; + const copy = action ? ACTION_COPY[action.kind] : null; return (
@@ -99,162 +133,182 @@ export default function AdminReportsPage() { actions={reports.length > 0 ? {reports.length} offen : undefined} /> - {isLoading ? ( -
- -
- ) : reports.length === 0 ? ( -
- -

Keine offenen Meldungen

-

Alles erledigt – neue Meldungen erscheinen hier und als Zähler im Menü.

-
- ) : ( -
- {reports.map((report) => ( -
- {/* Report header */} -
- -
-
- - Schwere: {SEVERITY_LABELS[report.severity] ?? report.severity} - - {REASON_LABELS[report.reason] ?? report.reason} - - gemeldet von{" "} - {report.reporter.name} - - · {formatEventTime(new Date(report.createdAt))} -
- {report.notes && ( -

- “{report.notes}” -

- )} -
- - {/* Actions */} -
- - {report.targetPostId && ( - - )} -
-
- - {/* Post preview */} - {report.post ? ( - + {!report.targetPostId && report.targetPetId && ( + + )} + {report.targetPostId && ( + + )}
- - ) : report.targetPetId ? ( -
- {report.targetPet?.avatarKey ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( - - - - )} -
-

- {report.targetPet ? formatPetName(report.targetPet) : "Gelöschtes Tier"} -

-

Profil gemeldet

-
+ + {/* Post preview */} + {report.post ? ( + + ) : report.targetPetId ? ( +
+ {report.targetPet?.avatarKey ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + + + )} +
+

+ {report.targetPet ? formatPetName(report.targetPet) : "Gelöschtes Tier"} +

+

Profil gemeldet

+
+
+ ) : null}
- ) : null} + ))}
- ))} - - )} + )} + + + + + {/* Confirm dialog */} { if (!o) closeDialog(); }}> - {action?.kind === "dismiss" ? "Meldung abweisen" : "Beitrag löschen"} + {copy?.title} @@ -273,6 +327,11 @@ export default function AdminReportsPage() { className="bg-muted border-border text-foreground placeholder:text-muted-foreground" /> + ) : action?.kind === "uphold" ? ( +

+ Die Profil-Meldung als begründet schließen? Handle vorher separat — verwarnen, sperren oder Inhalte + entfernen. Die Meldung zählt in der Auswertung als bestätigt. +

) : ( <>

@@ -291,14 +350,8 @@ export default function AdminReportsPage() { @@ -315,7 +368,7 @@ export default function AdminReportsPage() { } > {isPending && } - {action?.kind === "dismiss" ? "Abweisen" : "Beitrag löschen"} + {copy?.confirm} diff --git a/src/app/p/[secret]/users/page.tsx b/src/app/p/[secret]/users/page.tsx index b5dee0f..00b6d86 100644 --- a/src/app/p/[secret]/users/page.tsx +++ b/src/app/p/[secret]/users/page.tsx @@ -27,6 +27,7 @@ import { isActiveSponsor } from "@/lib/sponsor"; import { formatEventTime } from "@/lib/admin/format"; import { AdminPageHeader } from "@/components/admin/AdminSection"; import { StatusBadge } from "@/components/admin/StatusBadge"; +import { ReportStatsBadges, ReportStatsDetails } from "@/components/admin/ReportStats"; import { ADMIN_ROLE_LABEL } from "@/components/admin/admin-nav"; import { UserActionDialogs, type UserAction, type UserActionTarget } from "@/components/admin/users/UserActionDialogs"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -68,6 +69,9 @@ function UserStatus({ user }: { user: User }) { ), sponsor && Sponsor, + (user.reportStats.received.upheld > 0 || user.reportStats.frequentFalseReporter) && ( + + ), ].filter(Boolean); return

{badges.length > 0 ? badges : Aktiv}
; } @@ -333,6 +337,12 @@ export default function AdminUsersPage() {
{user.shadowban.reason}
)} +
+
Meldungen (entschieden)
+
+ +
+
Konto-ID
{user.id}
diff --git a/src/components/admin/ReportStats.tsx b/src/components/admin/ReportStats.tsx new file mode 100644 index 0000000..ccd459e --- /dev/null +++ b/src/components/admin/ReportStats.tsx @@ -0,0 +1,52 @@ +import { StatusBadge } from "@/components/admin/StatusBadge"; + +interface OutcomeCounts { + upheld: number; + dismissed: number; +} + +export interface ReportStatsData { + received: OutcomeCounts; + filed: OutcomeCounts; + frequentFalseReporter: boolean; +} + +const total = (c: OutcomeCounts) => c.upheld + c.dismissed; +const rate = (c: OutcomeCounts) => (total(c) === 0 ? null : Math.round((c.upheld / total(c)) * 100)); + +/** „7× · 4 bestätigt · 3 abgewiesen“ — oder „keine“. */ +export function formatOutcomeCounts(c: OutcomeCounts): string { + if (total(c) === 0) return "keine"; + return `${total(c)}× · ${c.upheld} bestätigt · ${c.dismissed} abgewiesen`; +} + +/** Abzeichen für Listen: bestätigte Meldungen gegen den Halter / das Tier und häufige Fehlmeldungen. */ +export function ReportStatsBadges({ stats }: { stats: ReportStatsData }) { + return ( + <> + {stats.received.upheld > 0 && ( + + {stats.received.upheld === 1 ? "1 Meldung bestätigt" : `${stats.received.upheld} Meldungen bestätigt`} + + )} + {stats.frequentFalseReporter && Häufige Fehlmeldungen} + + ); +} + +/** Zwei Zeilen „Gemeldet“ / „Meldet selbst“ für Detailansichten. */ +export function ReportStatsDetails({ stats }: { stats: ReportStatsData }) { + const filedRate = rate(stats.filed); + return ( +
+
Gemeldet
+
{formatOutcomeCounts(stats.received)}
+
Meldet selbst
+
+ {formatOutcomeCounts(stats.filed)} + {filedRate !== null && ({filedRate} % bestätigt)} + {stats.frequentFalseReporter && Häufige Fehlmeldungen} +
+
+ ); +} diff --git a/src/components/admin/pets/PetDetailSheet.tsx b/src/components/admin/pets/PetDetailSheet.tsx index 5a8ec5b..f36f238 100644 --- a/src/components/admin/pets/PetDetailSheet.tsx +++ b/src/components/admin/pets/PetDetailSheet.tsx @@ -12,6 +12,7 @@ import { PetAvatar } from "@/components/ui/pet-avatar"; import { getMediaUrl } from "@/lib/media-url"; import { PetRankSection } from "@/components/admin/pets/PetRankSection"; import { PetModerationSection } from "@/components/admin/pets/PetModerationSection"; +import { ReportStatsDetails } from "@/components/admin/ReportStats"; /** Detailansicht eines Tiers in der Admin-Tierverwaltung. */ export function PetDetailSheet({ petId, onClose }: { petId: string | null; onClose: () => void }) { @@ -82,6 +83,11 @@ export function PetDetailSheet({ petId, onClose }: { petId: string | null; onClo {pet.owner.shadowban && Shadowban} +
+

Meldungen zu diesem Tier (entschieden)

+ +
+ {(pet.bio || pet.adoptionStory) && (

Profiltexte

diff --git a/src/components/admin/reports/ReportStatsTab.tsx b/src/components/admin/reports/ReportStatsTab.tsx new file mode 100644 index 0000000..c37ecac --- /dev/null +++ b/src/components/admin/reports/ReportStatsTab.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useTRPC } from "@/trpc/client"; +import { PetAvatar } from "@/components/ui/pet-avatar"; +import { formatPetName } from "@/lib/pet-display-name"; +import { formatOutcomeCounts } from "@/components/admin/ReportStats"; + +type Overview = NonNullable["data"]>; +type Row = Overview["mostReported"][number]; + +function useOverview() { + const trpc = useTRPC(); + return useQuery(trpc.reportStatsAdmin.overview.queryOptions()); +} + +function RankingList({ title, hint, rows, empty }: { title: string; hint: string; rows: Row[]; empty: string }) { + return ( +
+
+

{title}

+

{hint}

+
+ {rows.length === 0 ? ( +

{empty}

+ ) : ( +
    + {rows.map((row, i) => ( +
  1. + {i + 1} + + {formatPetName(row.pet)} + {formatOutcomeCounts(row)} +
  2. + ))} +
+ )} +
+ ); +} + +/** Tab „Auswertung“: Melde-Statistik aus allen entschiedenen Meldungen (ReportDecision). */ +export function ReportStatsTab() { + const { data, isLoading } = useOverview(); + if (isLoading || !data) return ; + + const decided = data.totals.upheld + data.totals.dismissed; + const rule = data.falseReporterRule; + return ( +
+
+ {[ + ["Entschieden", decided], + ["Bestätigt", data.totals.upheld], + ["Abgewiesen", data.totals.dismissed], + ].map(([label, value]) => ( +
+
{value}
+
{label}
+
+ ))} +
+
+ + +
+

+ Gezählt wird seit dem 07.10.2026, nur von der Moderation entschiedene Meldungen. Gelöschte Konten bleiben + anonymisiert in den Summen. +

+
+ ); +} diff --git a/src/lib/delete-account.ts b/src/lib/delete-account.ts index b64ac48..1f7a8ca 100644 --- a/src/lib/delete-account.ts +++ b/src/lib/delete-account.ts @@ -2,6 +2,7 @@ import "server-only"; import type { PrismaClient } from "@prisma/client"; import { clerkClient } from "@clerk/nextjs/server"; import { deletePetCompletely } from "@/lib/delete-pet"; +import { anonymizeOwnerInReportDecisions } from "@/lib/report-decisions"; /** * Permanently deletes an Owner and everything cascading from their pets @@ -41,6 +42,10 @@ export async function deleteOwnerAccount(prisma: PrismaClient, ownerId: string): await prisma.owner.updateMany({ where: { invitedById: ownerId }, data: { invitedById: null } }); await prisma.inviteCode.updateMany({ where: { usedById: ownerId }, data: { usedById: null } }); + // Melde-Statistik: anonymisieren statt löschen — die Zählung der jeweils + // anderen Seite (Melder bzw. gemeldeter Halter) bleibt unverändert. + await anonymizeOwnerInReportDecisions(prisma, ownerId); + // Cascades AdminRole + UserBan + Warning await prisma.owner.delete({ where: { id: ownerId } }); diff --git a/src/lib/delete-pet.ts b/src/lib/delete-pet.ts index 4c737fd..ca724a9 100644 --- a/src/lib/delete-pet.ts +++ b/src/lib/delete-pet.ts @@ -2,6 +2,7 @@ import "server-only"; import type { PrismaClient } from "@prisma/client"; import { deleteFiles } from "@/lib/supabase-storage"; import { mux } from "@/lib/mux"; +import { forgetPetInReportDecisions } from "@/lib/report-decisions"; /** * Löscht ein Tier vollständig — genutzt von pets.delete (Owner), der @@ -97,6 +98,9 @@ export async function deletePetCompletely(prisma: PrismaClient, petId: string): // Message.senderPet hat kein Cascade und könnte den Pet-Delete sonst blockieren. await tx.$executeRaw`DELETE FROM "Conversation" WHERE "petAId" = ${petId} OR "petBId" = ${petId}`; + // Melde-Statistik: Zeilen bleiben (Zählung beim Halter), Tier-/Beitrags-IDs fallen weg. + await forgetPetInReportDecisions(tx, petId); + await tx.pet.delete({ where: { id: petId } }); }); diff --git a/src/lib/gdpr-export.ts b/src/lib/gdpr-export.ts index 6fc0be3..da1d99f 100644 --- a/src/lib/gdpr-export.ts +++ b/src/lib/gdpr-export.ts @@ -80,7 +80,7 @@ export async function buildDataExport(prisma: PrismaClient, ownerId: string) { if (!owner) return null; - const [invitedByPet, invitees] = await Promise.all([ + const [invitedByPet, invitees, reportDecisions] = await Promise.all([ owner.invitedById ? prisma.pet.findFirst({ where: { ownerId: owner.invitedById }, @@ -98,13 +98,34 @@ export async function buildDataExport(prisma: PrismaClient, ownerId: string) { pets: { orderBy: { createdAt: "asc" }, take: 1, select: { name: true } }, }, }), + // Melde-Statistik (report-decisions.ts) — die Gegenseite und der Moderator bleiben ungenannt. + prisma.reportDecision.findMany({ + where: { OR: [{ reporterOwnerId: ownerId }, { targetOwnerId: ownerId }] }, + orderBy: { decidedAt: "asc" }, + select: { + reporterOwnerId: true, + targetOwnerId: true, + reason: true, + outcome: true, + isProfileReport: true, + reportedAt: true, + decidedAt: true, + }, + }), ]); + const decisionView = (d: (typeof reportDecisions)[number]) => ({ + reason: d.reason, + outcome: d.outcome, + isProfileReport: d.isProfileReport, + reportedAt: d.reportedAt, + decidedAt: d.decidedAt, + }); return { exportedAt: new Date().toISOString(), scopeNote: "Enthält Social-Graph-, Health- und Nachrichtendaten, Consent-Zeitstempel, das Empfehlungsprogramm " + - "und frühere Einladungscodes. Werbeanzeigen-Interaktionen sind aktuell noch nicht enthalten. " + + "frühere Einladungscodes und entschiedene Meldungen (von dir und über dich). Werbeanzeigen-Interaktionen sind aktuell noch nicht enthalten. " + "Andere Personen werden nur über den Namen ihres Tiers genannt.", account: { ownerId: owner.id, @@ -124,6 +145,10 @@ export async function buildDataExport(prisma: PrismaClient, ownerId: string) { ban: owner.ban, adminRole: owner.adminRole, }, + moderation: { + reportsFiledDecisions: reportDecisions.filter((d) => d.reporterOwnerId === ownerId).map(decisionView), + reportsAboutYouDecisions: reportDecisions.filter((d) => d.targetOwnerId === ownerId).map(decisionView), + }, referralProgram: { ownCode: owner.referralCode, acquisitionSource: owner.acquisitionSource, diff --git a/src/lib/report-decisions.ts b/src/lib/report-decisions.ts new file mode 100644 index 0000000..4db2a14 --- /dev/null +++ b/src/lib/report-decisions.ts @@ -0,0 +1,201 @@ +import "server-only"; +import type { Prisma, PrismaClient, Report, ReportOutcome } from "@prisma/client"; + +/** + * Melde-Statistik: Jede Moderationsentscheidung zu einer Meldung landet als + * ReportDecision-Zeile (bestätigt/abgewiesen), bevor die Meldung selbst aus + * der Warteschlange gelöscht wird. Daraus: wie oft ein Halter/Tier gemeldet + * und bestätigt wurde, und wer häufig fälschlich meldet. + * + * Jeder Weg, der Meldungen per Moderationsentscheidung entfernt, muss + * reportDecisionOps() in derselben Transaktion ausführen: dismissReport, + * upholdReport, deletePostAsModerator (admin.ts) und petAdmin.delete. + * Nicht gezählt: Meldungen, die verschwinden, weil der Halter selbst löscht. + */ + +/** Ab so vielen entschiedenen Meldungen wird die Trefferquote bewertet. */ +export const FALSE_REPORTER_MIN_REPORTS = 5; +/** Darunter (bestätigt / entschieden) gilt ein Melder als „häufige Fehlmeldungen“. */ +export const FALSE_REPORTER_MAX_UPHELD_RATE = 0.2; + +export type DecidedReport = Pick; + +export interface OutcomeCounts { + upheld: number; + dismissed: number; +} + +export interface ReportStats { + /** Meldungen gegen diesen Halter / dieses Tier. */ + received: OutcomeCounts; + /** Von diesem Halter / Tier abgegebene Meldungen. */ + filed: OutcomeCounts; +} + +export const emptyCounts = (): OutcomeCounts => ({ upheld: 0, dismissed: 0 }); +const emptyStats = (): ReportStats => ({ received: emptyCounts(), filed: emptyCounts() }); +const unique = (ids: (string | null)[]) => [...new Set(ids.filter((id): id is string => !!id))]; + +/** + * createMany-Schritt für ein `$transaction([...])`. Löst Melder-Halter und die + * gemeldete Seite (Tier + Halter; bei Beiträgen das Tier, dem der Beitrag + * gehört) jetzt auf — später können Beitrag oder Tier schon gelöscht sein. + */ +export async function reportDecisionOps( + prisma: PrismaClient, + reports: DecidedReport[], + outcome: ReportOutcome, + decidedBy: string +): Promise[]> { + if (reports.length === 0) return []; + + const postIds = unique(reports.map((r) => r.targetPostId)); + const petIds = unique(reports.flatMap((r) => [r.reporterPetId, r.targetPetId])); + const [posts, pets] = await Promise.all([ + postIds.length > 0 + ? prisma.post.findMany({ + where: { id: { in: postIds } }, + select: { id: true, petId: true, pet: { select: { ownerId: true } } }, + }) + : [], + prisma.pet.findMany({ where: { id: { in: petIds } }, select: { id: true, ownerId: true } }), + ]); + const postById = new Map(posts.map((p) => [p.id, p])); + const ownerByPetId = new Map(pets.map((p) => [p.id, p.ownerId])); + + return [ + prisma.reportDecision.createMany({ + data: reports.map((r) => { + const post = r.targetPostId ? postById.get(r.targetPostId) : undefined; + const targetPetId = post?.petId ?? r.targetPetId; + return { + reporterPetId: r.reporterPetId, + reporterOwnerId: ownerByPetId.get(r.reporterPetId) ?? null, + targetPetId, + targetOwnerId: post?.pet.ownerId ?? (targetPetId ? (ownerByPetId.get(targetPetId) ?? null) : null), + targetPostId: r.targetPostId, + isProfileReport: !r.targetPostId, + reason: r.reason, + outcome, + reportedAt: r.createdAt, + decidedBy, + }; + }), + }), + ]; +} + +/** Konto gelöscht: Owner-IDs anonymisieren. Die Zeilen bleiben, damit die Zählung der Gegenseite gleich bleibt. */ +export async function anonymizeOwnerInReportDecisions(prisma: PrismaClient, ownerId: string): Promise { + await prisma.reportDecision.updateMany({ where: { reporterOwnerId: ownerId }, data: { reporterOwnerId: null } }); + await prisma.reportDecision.updateMany({ where: { targetOwnerId: ownerId }, data: { targetOwnerId: null } }); +} + +/** Tier gelöscht: seine Tier- und Beitrags-IDs anonymisieren (die Owner-Zählung bleibt). */ +export async function forgetPetInReportDecisions( + prisma: Pick, + petId: string +): Promise { + await prisma.reportDecision.updateMany({ where: { reporterPetId: petId }, data: { reporterPetId: null } }); + await prisma.reportDecision.updateMany({ + where: { targetPetId: petId }, + data: { targetPetId: null, targetPostId: null }, + }); +} + +function addCount(counts: OutcomeCounts, outcome: ReportOutcome, n: number) { + if (outcome === "UPHELD") counts.upheld += n; + else counts.dismissed += n; +} + +type StatsKey = "owner" | "pet"; +const STATS_FIELDS = { + owner: { target: "targetOwnerId", reporter: "reporterOwnerId" }, + pet: { target: "targetPetId", reporter: "reporterPetId" }, +} as const; + +/** Erhaltene und abgegebene Meldungen pro Halter bzw. Tier. Jede angefragte ID ist in der Map, auch ohne Einträge. */ +async function reportStatsBy(prisma: PrismaClient, key: StatsKey, ids: string[]): Promise> { + const stats = new Map(); + if (ids.length === 0) return stats; + for (const id of ids) stats.set(id, emptyStats()); + + const { target, reporter } = STATS_FIELDS[key]; + const [received, filed] = await Promise.all([ + prisma.reportDecision.groupBy({ by: [target, "outcome"], where: { [target]: { in: ids } }, _count: true }), + prisma.reportDecision.groupBy({ by: [reporter, "outcome"], where: { [reporter]: { in: ids } }, _count: true }), + ]); + for (const row of received) addCount(stats.get(row[target]!)!.received, row.outcome, row._count); + for (const row of filed) addCount(stats.get(row[reporter]!)!.filed, row.outcome, row._count); + return stats; +} + +export const reportStatsByOwner = (prisma: PrismaClient, ownerIds: string[]) => + reportStatsBy(prisma, "owner", ownerIds); +export const reportStatsByPet = (prisma: PrismaClient, petIds: string[]) => reportStatsBy(prisma, "pet", petIds); + +/** Erhaltene und abgegebene Meldungen eines Tiers. */ +export async function reportStatsForPet(prisma: PrismaClient, petId: string): Promise { + return (await reportStatsByPet(prisma, [petId])).get(petId)!; +} + +export function isFrequentFalseReporter(filed: OutcomeCounts): boolean { + const total = filed.upheld + filed.dismissed; + return total >= FALSE_REPORTER_MIN_REPORTS && filed.upheld / total < FALSE_REPORTER_MAX_UPHELD_RATE; +} + +/** Für die Admin-Oberfläche: Zählung plus serverseitig berechnetes Fehlmelder-Abzeichen. */ +export function withFalseReporterFlag(stats: ReportStats) { + return { ...stats, frequentFalseReporter: isFrequentFalseReporter(stats.filed) }; +} + +export interface PetCounts { + petId: string; + counts: OutcomeCounts; +} + +/** + * Ranglisten für den Tab „Auswertung“: Tiere mit den meisten bestätigten + * Meldungen gegen sich und Tiere mit häufigen Fehlmeldungen. Anonymisierte + * Zeilen (Tier gelöscht) zählen nur in den Summen. + */ +export async function reportRankings(prisma: PrismaClient, limit: number) { + const [received, filed, totalsRows] = await Promise.all([ + prisma.reportDecision.groupBy({ + by: ["targetPetId", "outcome"], + where: { targetPetId: { not: null } }, + _count: true, + }), + prisma.reportDecision.groupBy({ + by: ["reporterPetId", "outcome"], + where: { reporterPetId: { not: null } }, + _count: true, + }), + prisma.reportDecision.groupBy({ by: ["outcome"], _count: true }), + ]); + + const collect = (rows: { petId: string | null; outcome: ReportOutcome; _count: number }[]) => { + const byPet = new Map(); + for (const row of rows) { + if (!row.petId) continue; + const counts = byPet.get(row.petId) ?? emptyCounts(); + addCount(counts, row.outcome, row._count); + byPet.set(row.petId, counts); + } + return [...byPet].map(([petId, counts]) => ({ petId, counts })); + }; + + const totals = emptyCounts(); + for (const row of totalsRows) addCount(totals, row.outcome, row._count); + + const mostReported = collect(received.map((r) => ({ petId: r.targetPetId, outcome: r.outcome, _count: r._count }))) + .filter((p) => p.counts.upheld > 0) + .sort((a, b) => b.counts.upheld - a.counts.upheld || b.counts.dismissed - a.counts.dismissed) + .slice(0, limit); + const falseReporters = collect(filed.map((r) => ({ petId: r.reporterPetId, outcome: r.outcome, _count: r._count }))) + .filter((p) => isFrequentFalseReporter(p.counts)) + .sort((a, b) => b.counts.dismissed - a.counts.dismissed) + .slice(0, limit); + + return { totals, mostReported, falseReporters }; +} diff --git a/src/lib/report-feedback.ts b/src/lib/report-feedback.ts index b77bf67..8ea6338 100644 --- a/src/lib/report-feedback.ts +++ b/src/lib/report-feedback.ts @@ -32,3 +32,13 @@ export function reportUpheldMessage(target: string): string { `Danke, dass du hilfst, PawFeed sicher und freundlich zu halten!\n\n${SIGNATURE}` ); } + +/** Bestätigte Profil-Meldung: Die Maßnahme (Verwarnung, Sperre, Inhalte entfernt …) bleibt intern. */ +export function profileReportUpheldMessage(target: string): string { + return ( + `Hallo, danke für deine Meldung ${target}. Wir haben sie geprüft und entsprechende Maßnahmen ergriffen. ` + + `Danke, dass du hilfst, PawFeed sicher und freundlich zu halten! + +${SIGNATURE}` + ); +} diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts index 8df6a13..36d6d37 100644 --- a/src/trpc/routers/_app.ts +++ b/src/trpc/routers/_app.ts @@ -27,6 +27,7 @@ import { broadcastRouter } from "@/trpc/routers/broadcast"; import { insightsRouter } from "@/trpc/routers/insights"; import { referralsRouter } from "@/trpc/routers/referrals"; import { referralAdminRouter } from "@/trpc/routers/referral-admin"; +import { reportStatsAdminRouter } from "@/trpc/routers/report-stats-admin"; import { curatorAdminRouter } from "@/trpc/routers/curator-admin"; import { lostReportsRouter } from "@/trpc/routers/lost-reports"; import { caretakersRouter } from "@/trpc/routers/caretakers"; @@ -65,6 +66,7 @@ export const appRouter = router({ insights: insightsRouter, referrals: referralsRouter, referralAdmin: referralAdminRouter, + reportStatsAdmin: reportStatsAdminRouter, curatorAdmin: curatorAdminRouter, lostReports: lostReportsRouter, caretakers: caretakersRouter, diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index f4643d3..194c0d2 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -22,7 +22,19 @@ import { buildGreeting, renderBrandedMailHtml } from "@/lib/mail-template"; import { deleteOwnerAccount } from "@/lib/delete-account"; import { postDeleteOps } from "@/lib/delete-post"; import { ensureTeamPet, sendTeamMessage } from "@/lib/team-pet"; -import { describeReportTarget, reportDismissedMessage, reportUpheldMessage } from "@/lib/report-feedback"; +import { + describeReportTarget, + profileReportUpheldMessage, + reportDismissedMessage, + reportUpheldMessage, +} from "@/lib/report-feedback"; +import { + isFrequentFalseReporter, + reportDecisionOps, + reportStatsByOwner, + reportStatsByPet, + withFalseReporterFlag, +} from "@/lib/report-decisions"; import { matchesWordBlacklist, matchesLinkBlacklist } from "@/lib/blacklist"; import { getDailyVisitCount, getUniqueVisitorCount, lastNDays, daysInRange } from "@/lib/visit-tracking"; import { isMaintenanceModeEnabled, setMaintenanceMode } from "@/lib/maintenance"; @@ -110,6 +122,22 @@ async function updatePostLogged( return { success: true }; } +/** Name des gemeldeten Tiers bzw. des Tiers, dem der gemeldete Beitrag gehört — für die Team-Nachricht an den Melder. */ +async function reportTargetPetName( + prisma: PrismaClient, + report: { targetPostId: string | null; targetPetId: string | null } +): Promise { + if (report.targetPostId) { + const post = await prisma.post.findUnique({ + where: { id: report.targetPostId }, + select: { pet: { select: { name: true } } }, + }); + return post?.pet?.name ?? null; + } + if (!report.targetPetId) return null; + return (await prisma.pet.findUnique({ where: { id: report.targetPetId }, select: { name: true } }))?.name ?? null; +} + /** * Beitrag als Moderator löschen: Meldungen, Paw-Backs und repostCount räumt * postDeleteOps ab (delete-post.ts), Log im selben Schritt. Mit @@ -128,18 +156,16 @@ async function deletePostAsModerator( }); if (!post) throw new TRPCError({ code: "NOT_FOUND" }); - // Melder vor dem Löschen einsammeln — ihre Meldungen fallen mit dem Beitrag weg. - const reporterPetIds = notifyReporters - ? [ - ...new Set( - ( - await ctx.prisma.report.findMany({ where: { targetPostId: postId }, select: { reporterPetId: true } }) - ).map((r) => r.reporterPetId) - ), - ] - : []; + // Offene Meldungen vor dem Löschen einsammeln — sie fallen mit dem Beitrag weg, + // zählen in der Melde-Statistik aber als bestätigt (report-decisions.ts). + const reports = await ctx.prisma.report.findMany({ + where: { targetPostId: postId }, + select: { reporterPetId: true, targetPostId: true, targetPetId: true, reason: true, createdAt: true }, + }); + const reporterPetIds = notifyReporters ? [...new Set(reports.map((r) => r.reporterPetId))] : []; await ctx.prisma.$transaction([ + ...(await reportDecisionOps(ctx.prisma, reports, "UPHELD", ctx.userId)), ...(await postDeleteOps(ctx.prisma, postId, post.repost?.originalPostId)), modLog(ctx.prisma, ctx, callerRole, { action: "DELETE_POST", targetType: "POST", targetId: postId, reason }), ]); @@ -664,6 +690,7 @@ export const adminRouter = router({ ? (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); @@ -672,6 +699,7 @@ export const adminRouter = router({ )?.emailAddress; return { ...owner, + reportStats: withFalseReporterFlag(reportStats.get(owner.id)!), clerk: clerkUser ? { email: primaryEmail ?? null, @@ -1209,6 +1237,8 @@ export const adminRouter = router({ ? ((await ctx.prisma.pet.findMany({ where: { id: { in: targetPetIds } }, select: petIdentitySelect })) ?? []) : []; const targetPetMap = new Map(targetPets.map((p) => [p.id, p])); + // Melder mit „häufigen Fehlmeldungen“ markieren (Melde-Statistik, report-decisions.ts). + const reporterStats = await reportStatsByPet(ctx.prisma, [...new Set(reports.map((r) => r.reporterPetId))]); // Severity-first, newest-within-severity — reports don't carry an // explicit severity column, it's derived from `reason` (Gitea #10). @@ -1218,6 +1248,7 @@ export const adminRouter = router({ severity: REPORT_SEVERITY[r.reason], post: r.targetPostId ? (postMap.get(r.targetPostId) ?? null) : null, targetPet: r.targetPetId ? (targetPetMap.get(r.targetPetId) ?? null) : null, + reporterFrequentFalseReporter: isFrequentFalseReporter(reporterStats.get(r.reporterPetId)!.filed), })) .sort( (a, b) => @@ -1274,6 +1305,7 @@ export const adminRouter = router({ const report = await ctx.prisma.report.findUnique({ where: { id: input.reportId } }); if (!report) throw new TRPCError({ code: "NOT_FOUND" }); await ctx.prisma.$transaction([ + ...(await reportDecisionOps(ctx.prisma, [report], "DISMISSED", ctx.userId)), ctx.prisma.report.delete({ where: { id: input.reportId } }), modLog(ctx.prisma, ctx, callerRole, { action: "DISMISS_REPORT", @@ -1287,17 +1319,7 @@ export const adminRouter = router({ // must not fail the request. if (input.notifyReporter) { try { - const targetPetName = report.targetPostId - ? (( - await ctx.prisma.post.findUnique({ - where: { id: report.targetPostId }, - select: { pet: { select: { name: true } } }, - }) - )?.pet?.name ?? null) - : report.targetPetId - ? ((await ctx.prisma.pet.findUnique({ where: { id: report.targetPetId }, select: { name: true } })) - ?.name ?? null) - : null; + const targetPetName = await reportTargetPetName(ctx.prisma, report); const target = describeReportTarget(report.targetPostId ? "post" : "pet", targetPetName); await sendTeamMessage(ctx.prisma, report.reporterPetId, reportDismissedMessage(target, input.message ?? "")); } catch (err) { @@ -1307,6 +1329,35 @@ export const adminRouter = router({ return { success: true }; }), + // Profil-Meldung bestätigen: Der Moderator hat (separat) gehandelt — z. B. + // verwarnt, gesperrt oder Inhalte entfernt. Beitrags-Meldungen werden über + // „Beitrag löschen“ bestätigt. Zählt in der Melde-Statistik als bestätigt. + upholdReport: protectedProcedure + .input(z.object({ reportId: z.string().min(1), notifyReporter: z.boolean().default(true) })) + .mutation(async ({ ctx, input }) => { + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); + const report = await ctx.prisma.report.findUnique({ where: { id: input.reportId } }); + if (!report) throw new TRPCError({ code: "NOT_FOUND" }); + if (report.targetPostId) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Beitrags-Meldungen über „Beitrag löschen“ bestätigen." }); + } + await ctx.prisma.$transaction([ + ...(await reportDecisionOps(ctx.prisma, [report], "UPHELD", ctx.userId)), + ctx.prisma.report.delete({ where: { id: input.reportId } }), + modLog(ctx.prisma, ctx, callerRole, { action: "UPHOLD_REPORT", targetType: "REPORT", targetId: input.reportId }), + ]); + + if (input.notifyReporter) { + try { + const target = describeReportTarget("pet", await reportTargetPetName(ctx.prisma, report)); + await sendTeamMessage(ctx.prisma, report.reporterPetId, profileReportUpheldMessage(target)); + } catch (err) { + console.error("[admin] report upheld notification failed", err); + } + } + return { success: true }; + }), + deleteReportedPost: protectedProcedure .input( z.object({ diff --git a/src/trpc/routers/pet-admin.ts b/src/trpc/routers/pet-admin.ts index b87800f..f9d49ed 100644 --- a/src/trpc/routers/pet-admin.ts +++ b/src/trpc/routers/pet-admin.ts @@ -7,6 +7,7 @@ import { router, protectedProcedure } from "@/trpc/init"; import { modLog } from "@/lib/admin/mod-log"; import { assertAdmin } from "@/trpc/routers/admin"; import { deletePetCompletely } from "@/lib/delete-pet"; +import { reportDecisionOps, reportStatsForPet, withFalseReporterFlag } from "@/lib/report-decisions"; import { deleteFiles } from "@/lib/supabase-storage"; import { ensureTeamPet, sendTeamMessage } from "@/lib/team-pet"; import { sendMail } from "@/lib/mail"; @@ -180,6 +181,7 @@ export const petAdminRouter = router({ pet: { ...pet, species: withGermanName(pet.species), breed: pet.breed ? withGermanName(pet.breed) : null }, ranks, ownerContact, + reportStats: withFalseReporterFlag(await reportStatsForPet(ctx.prisma, pet.id)), }; }), @@ -304,6 +306,18 @@ export const petAdminRouter = router({ throw new TRPCError({ code: "BAD_REQUEST", message: "Das Kurator-Tier „PawFeed Team“ kann nicht gelöscht werden." }); } + // Offene Meldungen zum Tier und seinen Beiträgen zählen als bestätigt + // (report-decisions.ts). Vor dem Löschen schreiben: deletePetCompletely + // entfernt die Meldungen und anonymisiert danach die IDs dieses Tiers. + const postIds = ( + await ctx.prisma.post.findMany({ where: { petId: input.petId }, select: { id: true } }) + ).map((p) => p.id); + const openReports = await ctx.prisma.report.findMany({ + where: { OR: [{ targetPetId: input.petId }, { targetPostId: { in: postIds } }] }, + select: { reporterPetId: true, targetPostId: true, targetPetId: true, reason: true, createdAt: true }, + }); + await ctx.prisma.$transaction(await reportDecisionOps(ctx.prisma, openReports, "UPHELD", ctx.userId)); + await deletePetCompletely(ctx.prisma, input.petId); await modLog(ctx.prisma, ctx, callerRole, { action: "DELETE_PET", diff --git a/src/trpc/routers/report-stats-admin.ts b/src/trpc/routers/report-stats-admin.ts new file mode 100644 index 0000000..f67a15b --- /dev/null +++ b/src/trpc/routers/report-stats-admin.ts @@ -0,0 +1,39 @@ +import "server-only"; +import { AdminRoleType } from "@prisma/client"; +import { router, protectedProcedure } from "@/trpc/init"; +import { assertAdmin } from "@/trpc/routers/admin"; +import { petIdentitySelect } from "@/repositories/pet-repository"; +import { + FALSE_REPORTER_MAX_UPHELD_RATE, + FALSE_REPORTER_MIN_REPORTS, + reportRankings, + type PetCounts, +} from "@/lib/report-decisions"; + +const RANKING_LIMIT = 10; + +/** Melde-Statistik (Tab „Auswertung“ auf der Meldungen-Seite), Daten aus ReportDecision. */ +export const reportStatsAdminRouter = router({ + overview: protectedProcedure.query(async ({ ctx }) => { + await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); + const { totals, mostReported, falseReporters } = await reportRankings(ctx.prisma, RANKING_LIMIT); + + const petIds = [...new Set([...mostReported, ...falseReporters].map((p) => p.petId))]; + const pets = petIds.length + ? await ctx.prisma.pet.findMany({ where: { id: { in: petIds } }, select: petIdentitySelect }) + : []; + const petById = new Map(pets.map((p) => [p.id, p])); + const withPet = (rows: PetCounts[]) => + rows.flatMap((row) => { + const pet = petById.get(row.petId); + return pet ? [{ pet, ...row.counts }] : []; + }); + + return { + totals, + mostReported: withPet(mostReported), + falseReporters: withPet(falseReporters), + falseReporterRule: { minReports: FALSE_REPORTER_MIN_REPORTS, maxUpheldRate: FALSE_REPORTER_MAX_UPHELD_RATE }, + }; + }), +});