feat(admin): Melde-Statistik — wer oft gemeldet wird und wer oft falsch meldet

- 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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 07:16:08 +02:00
1 parent 0880d87f31
commit 77cdebec0f
25 files changed
+1213 -177

No files matched your search

+2
View File
@@ -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`
+4
View File
@@ -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;
+33
View File
@@ -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 {
+43
View File
@@ -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");
});
});
@@ -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();
});
});
+18
View File
@@ -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");
+12 -3
View File
@@ -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(),
+26
View File
@@ -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" });
+184
View File
@@ -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<typeof createMockPrisma>;
// 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 } }]);
});
});
+41
View File
@@ -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<typeof createMockPrisma>) {
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 }]);
});
});
+29 -1
View File
@@ -278,6 +278,30 @@ export default function DatenschutzPage() {
</p>
</LegalSection>
<LegalSection number="4e" title="Gemeldete Inhalte und Melde-Statistik">
<p>
Sie können Beiträge und Profile melden. Unsere Moderation prüft jede Meldung und entscheidet,
ob sie begründet ist (z.&nbsp;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.
</p>
<p className="mt-2">
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.&nbsp;V.&nbsp;m. Art. 23 Digital Services Act). Die Einträge
sehen nur Moderatoren. Wer gemeldet hat, erfährt die gemeldete Person nicht.
</p>
<p className="mt-2">
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.
</p>
</LegalSection>
<LegalSection number="5" title="Cookies und Tracking">
<p>
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).
</p>
<p className="mt-3">
Einträge der Melde-Statistik bewahren wir auf, solange Ihr Konto besteht; bei der Löschung
des Kontos werden sie anonymisiert (siehe Abschnitt 4e).
</p>
<p className="mt-3">
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() {
<LegalSection number="10" title="Aktualität dieser Erklärung">
<p>
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.
</p>
+202 -149
View File
@@ -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<string, StatusTone> = { 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<ReportAction["kind"], { title: string; confirm: string; notify: string; notifyHint: string }> = {
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<ReportAction | null>(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 (
<div className="mx-auto max-w-5xl space-y-6">
@@ -99,162 +133,182 @@ export default function AdminReportsPage() {
actions={reports.length > 0 ? <StatusBadge tone="warning">{reports.length} offen</StatusBadge> : undefined}
/>
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : reports.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-2xl border border-dashed border-border py-16 text-center">
<CheckCheck className="h-8 w-8 text-success" aria-hidden />
<p className="text-sm font-semibold text-foreground">Keine offenen Meldungen</p>
<p className="text-sm text-muted-foreground">Alles erledigt – neue Meldungen erscheinen hier und als Zähler im Menü.</p>
</div>
) : (
<div className="space-y-3">
{reports.map((report) => (
<div
key={report.id}
className="overflow-hidden rounded-2xl border border-border bg-card"
>
{/* Report header */}
<div className="flex flex-wrap items-start gap-3 px-5 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<StatusBadge tone={SEVERITY_TONE[report.severity] ?? "neutral"}>
Schwere: {SEVERITY_LABELS[report.severity] ?? report.severity}
</StatusBadge>
<StatusBadge tone="neutral">{REASON_LABELS[report.reason] ?? report.reason}</StatusBadge>
<span className="text-xs text-muted-foreground">
gemeldet von{" "}
<span className="text-foreground font-medium">{report.reporter.name}</span>
</span>
<span className="text-xs text-muted-foreground">· {formatEventTime(new Date(report.createdAt))}</span>
</div>
{report.notes && (
<p className="text-sm text-foreground mt-1 leading-snug">
&ldquo;{report.notes}&rdquo;
</p>
)}
</div>
{/* Actions */}
<div className="flex shrink-0 gap-2">
<Button size="sm" variant="outline" onClick={() => setAction({ kind: "dismiss", reportId: report.id })}>
<X className="mr-1.5 h-4 w-4" aria-hidden />
Abweisen
</Button>
{report.targetPostId && (
<Button size="sm" variant="destructive" onClick={() => setAction({ kind: "deletePost", postId: report.targetPostId! })}>
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden />
Beitrag löschen
</Button>
)}
</div>
</div>
{/* Post preview */}
{report.post ? (
<button
type="button"
onClick={() => setDetailPostId(report.post!.id)}
className="mx-5 mb-4 flex w-[calc(100%-2.5rem)] cursor-pointer gap-3 rounded-xl border border-border p-3 text-left transition-colors hover:bg-muted/60"
title="Beitrag vollständig ansehen"
<Tabs defaultValue="open">
<TabsList>
<TabsTrigger value="open">Offen</TabsTrigger>
<TabsTrigger value="stats">Auswertung</TabsTrigger>
</TabsList>
<TabsContent value="open" className="pt-4">
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : reports.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-2xl border border-dashed border-border py-16 text-center">
<CheckCheck className="h-8 w-8 text-success" aria-hidden />
<p className="text-sm font-semibold text-foreground">Keine offenen Meldungen</p>
<p className="text-sm text-muted-foreground">Alles erledigt – neue Meldungen erscheinen hier und als Zähler im Menü.</p>
</div>
) : (
<div className="space-y-3">
{reports.map((report) => (
<div
key={report.id}
className="overflow-hidden rounded-2xl border border-border bg-card"
>
{/* Image strip */}
<div className="flex gap-1 shrink-0">
{report.post.images.length > 0 ? (
report.post.images.map((img, i) => (
<BlurImage
key={i}
src={getMediaUrl(img.storageKey) ?? ""}
blurhash={img.blurhash}
alt=""
className="h-20 w-20 rounded-lg object-cover"
/>
))
) : (
<div className="h-20 w-20 rounded-lg bg-muted flex items-center justify-center">
<ImageOff className="h-6 w-6 text-muted-foreground/70" />
</div>
)}
</div>
{/* Report header */}
<div className="flex flex-wrap items-start gap-3 px-5 py-4">
{/* Post meta */}
<div className="flex-1 min-w-0 flex flex-col justify-between">
<div className="space-y-1">
{/* Pet */}
<div className="flex items-center gap-1.5">
{report.post.pet.avatarKey ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getMediaUrl(report.post.pet.avatarKey) ?? ""}
alt=""
className="h-5 w-5 rounded-full object-cover"
/>
) : (
<div className="h-5 w-5 rounded-full bg-secondary" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<StatusBadge tone={SEVERITY_TONE[report.severity] ?? "neutral"}>
Schwere: {SEVERITY_LABELS[report.severity] ?? report.severity}
</StatusBadge>
<StatusBadge tone="neutral">{REASON_LABELS[report.reason] ?? report.reason}</StatusBadge>
<span className="text-xs text-muted-foreground">
gemeldet von{" "}
<span className="text-foreground font-medium">{report.reporter.name}</span>
</span>
{report.reporterFrequentFalseReporter && (
<StatusBadge tone="warning">Melder: häufige Fehlmeldungen</StatusBadge>
)}
<span className="text-xs font-medium text-foreground">
{report.post.pet.name}
</span>
<span className="text-xs text-muted-foreground/70">
{new Date(report.post.createdAt).toLocaleDateString("de-DE")}
</span>
<span className="text-xs text-muted-foreground">· {formatEventTime(new Date(report.createdAt))}</span>
</div>
{/* Caption */}
{report.post.caption ? (
<p className="text-sm text-foreground leading-snug line-clamp-3">
{report.post.caption}
{report.notes && (
<p className="text-sm text-foreground mt-1 leading-snug">
&ldquo;{report.notes}&rdquo;
</p>
) : (
<p className="text-xs italic text-muted-foreground">Ohne Bildtext</p>
)}
</div>
{/* Counts */}
<div className="flex gap-3 text-xs text-muted-foreground mt-1">
<span className="flex items-center gap-1" title="Pfoten">
<PawPrint className="h-3 w-3" aria-hidden />
{report.post.reactionCount}
</span>
<span className="flex items-center gap-1">
<MessageCircle className="h-3 w-3" />
{report.post.commentCount}
</span>
{/* Actions */}
<div className="flex shrink-0 gap-2">
<Button size="sm" variant="outline" onClick={() => setAction({ kind: "dismiss", reportId: report.id })}>
<X className="mr-1.5 h-4 w-4" aria-hidden />
Abweisen
</Button>
{!report.targetPostId && report.targetPetId && (
<Button size="sm" variant="outline" onClick={() => setAction({ kind: "uphold", reportId: report.id })}>
<ShieldCheck className="mr-1.5 h-4 w-4" aria-hidden />
Bestätigen
</Button>
)}
{report.targetPostId && (
<Button size="sm" variant="destructive" onClick={() => setAction({ kind: "deletePost", postId: report.targetPostId! })}>
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden />
Beitrag löschen
</Button>
)}
</div>
</div>
</button>
) : report.targetPetId ? (
<div className="mx-5 mb-4 flex items-center gap-3 rounded-xl border border-border p-3">
{report.targetPet?.avatarKey ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={getMediaUrl(report.targetPet.avatarKey) ?? ""} alt="" className="h-10 w-10 rounded-full object-cover" />
) : (
<span className="grid h-10 w-10 place-items-center rounded-full bg-muted text-muted-foreground">
<UserRound className="h-5 w-5" aria-hidden />
</span>
)}
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
{report.targetPet ? formatPetName(report.targetPet) : "Gelöschtes Tier"}
</p>
<p className="text-xs text-muted-foreground">Profil gemeldet</p>
</div>
{/* Post preview */}
{report.post ? (
<button
type="button"
onClick={() => setDetailPostId(report.post!.id)}
className="mx-5 mb-4 flex w-[calc(100%-2.5rem)] cursor-pointer gap-3 rounded-xl border border-border p-3 text-left transition-colors hover:bg-muted/60"
title="Beitrag vollständig ansehen"
>
{/* Image strip */}
<div className="flex gap-1 shrink-0">
{report.post.images.length > 0 ? (
report.post.images.map((img, i) => (
<BlurImage
key={i}
src={getMediaUrl(img.storageKey) ?? ""}
blurhash={img.blurhash}
alt=""
className="h-20 w-20 rounded-lg object-cover"
/>
))
) : (
<div className="h-20 w-20 rounded-lg bg-muted flex items-center justify-center">
<ImageOff className="h-6 w-6 text-muted-foreground/70" />
</div>
)}
</div>
{/* Post meta */}
<div className="flex-1 min-w-0 flex flex-col justify-between">
<div className="space-y-1">
{/* Pet */}
<div className="flex items-center gap-1.5">
{report.post.pet.avatarKey ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getMediaUrl(report.post.pet.avatarKey) ?? ""}
alt=""
className="h-5 w-5 rounded-full object-cover"
/>
) : (
<div className="h-5 w-5 rounded-full bg-secondary" />
)}
<span className="text-xs font-medium text-foreground">
{report.post.pet.name}
</span>
<span className="text-xs text-muted-foreground/70">
{new Date(report.post.createdAt).toLocaleDateString("de-DE")}
</span>
</div>
{/* Caption */}
{report.post.caption ? (
<p className="text-sm text-foreground leading-snug line-clamp-3">
{report.post.caption}
</p>
) : (
<p className="text-xs italic text-muted-foreground">Ohne Bildtext</p>
)}
</div>
{/* Counts */}
<div className="flex gap-3 text-xs text-muted-foreground mt-1">
<span className="flex items-center gap-1" title="Pfoten">
<PawPrint className="h-3 w-3" aria-hidden />
{report.post.reactionCount}
</span>
<span className="flex items-center gap-1">
<MessageCircle className="h-3 w-3" />
{report.post.commentCount}
</span>
</div>
</div>
</button>
) : report.targetPetId ? (
<div className="mx-5 mb-4 flex items-center gap-3 rounded-xl border border-border p-3">
{report.targetPet?.avatarKey ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={getMediaUrl(report.targetPet.avatarKey) ?? ""} alt="" className="h-10 w-10 rounded-full object-cover" />
) : (
<span className="grid h-10 w-10 place-items-center rounded-full bg-muted text-muted-foreground">
<UserRound className="h-5 w-5" aria-hidden />
</span>
)}
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
{report.targetPet ? formatPetName(report.targetPet) : "Gelöschtes Tier"}
</p>
<p className="text-xs text-muted-foreground">Profil gemeldet</p>
</div>
</div>
) : null}
</div>
) : null}
))}
</div>
))}
</div>
)}
)}
</TabsContent>
<TabsContent value="stats" className="pt-4">
<ReportStatsTab />
</TabsContent>
</Tabs>
{/* Confirm dialog */}
<Dialog open={!!action} onOpenChange={(o) => { if (!o) closeDialog(); }}>
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<DialogHeader>
<DialogTitle>
{action?.kind === "dismiss" ? "Meldung abweisen" : "Beitrag löschen"}
{copy?.title}
</DialogTitle>
</DialogHeader>
@@ -273,6 +327,11 @@ export default function AdminReportsPage() {
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</>
) : action?.kind === "uphold" ? (
<p className="text-sm text-muted-foreground">
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.
</p>
) : (
<>
<p className="text-sm text-muted-foreground">
@@ -291,14 +350,8 @@ export default function AdminReportsPage() {
<label className="flex items-start gap-3 rounded-lg border border-border px-3 py-2.5 text-sm">
<Switch checked={notifyReporter} onCheckedChange={setNotifyReporter} className="mt-0.5" />
<span>
<span className="text-foreground">
{action?.kind === "dismiss" ? "Melder benachrichtigen" : "Allen Meldern danken"}
</span>
<span className="block text-xs text-muted-foreground">
{action?.kind === "dismiss"
? "Team-Nachricht: Meldung geprüft, kein Verstoß — plus deine Begründung."
: "Team-Nachricht: Meldung geprüft, Inhalt entfernt, danke fürs Melden."}
</span>
<span className="text-foreground">{copy?.notify}</span>
<span className="block text-xs text-muted-foreground">{copy?.notifyHint}</span>
</span>
</label>
@@ -315,7 +368,7 @@ export default function AdminReportsPage() {
}
>
{isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
{action?.kind === "dismiss" ? "Abweisen" : "Beitrag löschen"}
{copy?.confirm}
</Button>
</DialogFooter>
</DialogContent>
+10
View File
@@ -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 }) {
</StatusBadge>
),
sponsor && <StatusBadge key="sponsor" tone="info">Sponsor</StatusBadge>,
(user.reportStats.received.upheld > 0 || user.reportStats.frequentFalseReporter) && (
<ReportStatsBadges key="reports" stats={user.reportStats} />
),
].filter(Boolean);
return <div className="flex flex-wrap gap-1">{badges.length > 0 ? badges : <StatusBadge tone="success">Aktiv</StatusBadge>}</div>;
}
@@ -333,6 +337,12 @@ export default function AdminUsersPage() {
<dd className="text-foreground">{user.shadowban.reason}</dd>
</div>
)}
<div className="sm:col-span-2 lg:col-span-3">
<dt className="text-xs text-muted-foreground">Meldungen (entschieden)</dt>
<dd>
<ReportStatsDetails stats={user.reportStats} />
</dd>
</div>
<div className="sm:col-span-2 lg:col-span-3">
<dt className="text-xs text-muted-foreground">Konto-ID</dt>
<dd className="break-all font-mono text-xs text-muted-foreground">{user.id}</dd>
+52
View File
@@ -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 && (
<StatusBadge tone="danger">
{stats.received.upheld === 1 ? "1 Meldung bestätigt" : `${stats.received.upheld} Meldungen bestätigt`}
</StatusBadge>
)}
{stats.frequentFalseReporter && <StatusBadge tone="warning">Häufige Fehlmeldungen</StatusBadge>}
</>
);
}
/** Zwei Zeilen „Gemeldet“ / „Meldet selbst“ für Detailansichten. */
export function ReportStatsDetails({ stats }: { stats: ReportStatsData }) {
const filedRate = rate(stats.filed);
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
<dt className="text-muted-foreground">Gemeldet</dt>
<dd className="text-foreground">{formatOutcomeCounts(stats.received)}</dd>
<dt className="text-muted-foreground">Meldet selbst</dt>
<dd className="flex flex-wrap items-center gap-2 text-foreground">
{formatOutcomeCounts(stats.filed)}
{filedRate !== null && <span className="text-muted-foreground">({filedRate} % bestätigt)</span>}
{stats.frequentFalseReporter && <StatusBadge tone="warning">Häufige Fehlmeldungen</StatusBadge>}
</dd>
</dl>
);
}
@@ -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 && <Badge variant="secondary">Shadowban</Badge>}
</section>
<section className="space-y-1">
<h3 className="text-sm font-semibold">Meldungen zu diesem Tier (entschieden)</h3>
<ReportStatsDetails stats={data.reportStats} />
</section>
{(pet.bio || pet.adoptionStory) && (
<section className="space-y-1 text-sm">
<h3 className="font-semibold">Profiltexte</h3>
@@ -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<ReturnType<typeof useOverview>["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 (
<section className="space-y-2 rounded-2xl border border-border bg-card p-5">
<div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">{hint}</p>
</div>
{rows.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">{empty}</p>
) : (
<ol className="divide-y divide-border">
{rows.map((row, i) => (
<li key={row.pet.id} className="flex items-center gap-3 py-2.5">
<span className="w-5 text-right font-mono text-xs tabular-nums text-muted-foreground">{i + 1}</span>
<PetAvatar name={row.pet.name} avatarKey={row.pet.avatarKey} size="sm" />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{formatPetName(row.pet)}</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{formatOutcomeCounts(row)}</span>
</li>
))}
</ol>
)}
</section>
);
}
/** Tab „Auswertung“: Melde-Statistik aus allen entschiedenen Meldungen (ReportDecision). */
export function ReportStatsTab() {
const { data, isLoading } = useOverview();
if (isLoading || !data) return <Loader2 className="mx-auto my-12 h-5 w-5 animate-spin text-muted-foreground" />;
const decided = data.totals.upheld + data.totals.dismissed;
const rule = data.falseReporterRule;
return (
<div className="space-y-4">
<dl className="grid grid-cols-3 gap-3 text-center">
{[
["Entschieden", decided],
["Bestätigt", data.totals.upheld],
["Abgewiesen", data.totals.dismissed],
].map(([label, value]) => (
<div key={label} className="rounded-2xl border border-border bg-card p-4">
<dd className="font-mono text-2xl font-semibold tabular-nums text-foreground">{value}</dd>
<dt className="text-xs text-muted-foreground">{label}</dt>
</div>
))}
</dl>
<div className="grid gap-4 md:grid-cols-2">
<RankingList
title="Am häufigsten bestätigt gemeldet"
hint="Tiere, gegen die Meldungen bestätigt wurden (Beitrag gelöscht, Profil-Meldung bestätigt, Tier gelöscht)."
rows={data.mostReported}
empty="Noch keine bestätigten Meldungen."
/>
<RankingList
title="Häufige Fehlmeldungen"
hint={`Melder mit mindestens ${rule.minReports} entschiedenen Meldungen, von denen weniger als ${Math.round(rule.maxUpheldRate * 100)} % bestätigt wurden.`}
rows={data.falseReporters}
empty="Niemand fällt bisher auf."
/>
</div>
<p className="text-xs text-muted-foreground">
Gezählt wird seit dem 07.10.2026, nur von der Moderation entschiedene Meldungen. Gelöschte Konten bleiben
anonymisiert in den Summen.
</p>
</div>
);
}
+5
View File
@@ -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 } });
+4
View File
@@ -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 } });
});
+27 -2
View File
@@ -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,
+201
View File
@@ -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<Report, "reporterPetId" | "targetPostId" | "targetPetId" | "reason" | "createdAt">;
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<Prisma.PrismaPromise<unknown>[]> {
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<void> {
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<Prisma.TransactionClient, "reportDecision">,
petId: string
): Promise<void> {
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<Map<string, ReportStats>> {
const stats = new Map<string, ReportStats>();
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<ReportStats> {
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<string, OutcomeCounts>();
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 };
}
+10
View File
@@ -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}`
);
}
+2
View File
@@ -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,
+73 -22
View File
@@ -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<string | null> {
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({
+14
View File
@@ -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",
+39
View File
@@ -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 },
};
}),
});