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:
1 parent
0880d87f31
commit
77cdebec0f
25 files changed
+1213
-177
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 } }]);
|
||||
});
|
||||
});
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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. 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. V. 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
@@ -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">
|
||||
“{report.notes}”
|
||||
</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">
|
||||
“{report.notes}”
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user