feat(admin): Team-Nachricht an Melder bei abgewiesenem/bestaetigtem Report

found.md: Abweisen schickt dem Melder eine Team-Nachricht mit optionaler
Begruendung (auch im ModerationLog), Loeschen eines gemeldeten Beitrags
dankt jedem Melder einmal. Beides per Schalter im Admin-Dialog abschaltbar,
best-effort nach der Moderation. Die interne Loesch-Begruendung geht nicht
an den Melder.

Fix nebenbei: deleteReportedPost loescht jetzt die Reports des Beitrags
(targetPostId ist keine Relation, sie blieben bisher verwaist in der Liste).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-04 13:09:39 +02:00
1 parent 01459c7e0e
commit f51b158989
6 files changed
+296 -11

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
/**
* found.md: Rückmeldung an den Melder per Team-Nachricht — Begründung bei
* abgewiesenem Report, Dank bei bestätigter Löschung.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn().mockResolvedValue({ userId: "admin-1" }),
clerkClient: vi.fn(),
}));
vi.mock("@/lib/supabase-storage", () => ({
createSignedUploadUrl: vi.fn(),
ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"],
MAX_FILE_SIZE: 10 * 1024 * 1024,
MUSIC_STORAGE_BUCKET: "music-tracks",
MUSIC_ALLOWED_CONTENT_TYPES: ["audio/mpeg", "audio/mp4", "audio/aac"],
MAX_MUSIC_FILE_SIZE: 5 * 1024 * 1024,
}));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/gdpr-export", () => ({ buildDataExport: vi.fn() }));
vi.mock("@/lib/data-export-storage", () => ({ uploadExport: vi.fn() }));
vi.mock("@/lib/mail", () => ({ sendMail: vi.fn() }));
vi.mock("@/lib/maintenance", () => ({ isMaintenanceModeEnabled: vi.fn(), setMaintenanceMode: vi.fn() }));
const { mockSendTeamMessage } = vi.hoisted(() => ({ mockSendTeamMessage: vi.fn() }));
vi.mock("@/lib/team-pet", () => ({
ensureTeamPet: vi.fn(),
sendTeamMessage: (...args: unknown[]) => mockSendTeamMessage(...args),
}));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
async function createAdminCaller() {
const { adminRouter } = await import("@/trpc/routers/admin");
const { initTRPC } = await import("@trpc/server");
const mockPrisma = createMockPrisma();
mockPrisma.$transaction.mockImplementation((arg: unknown) => Promise.all(arg as unknown[]));
const t = initTRPC.context<{ userId: string | null; prisma: typeof mockPrisma }>().create();
const testRouter = t.router({ admin: adminRouter });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const caller = testRouter.createCaller({ userId: "admin-1", prisma: mockPrisma as any });
return { caller, mockPrisma };
}
afterEach(() => {
vi.unstubAllEnvs();
mockSendTeamMessage.mockReset();
});
describe("admin.dismissReport reporter feedback", () => {
it("sends the reporter a team message with the reason, naming the reported post's pet", 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 });
mockPrisma.post.findUnique.mockResolvedValue({ pet: { name: "Bubi" } });
mockSendTeamMessage.mockResolvedValue(undefined);
await caller.admin.dismissReport({ reportId: "r1", message: "Das ist ein normales Spielvideo." });
expect(mockSendTeamMessage).toHaveBeenCalledTimes(1);
const [, petId, body] = mockSendTeamMessage.mock.calls[0];
expect(petId).toBe("reporter-1");
expect(body).toContain("den Beitrag von Bubi");
expect(body).toContain("keinen Verstoß");
expect(body).toContain("Begründung: Das ist ein normales Spielvideo.");
expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ reason: "Das ist ein normales Spielvideo." }) })
);
});
it("names the profile for a pet report and omits an empty reason", 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" });
mockPrisma.pet.findUnique.mockResolvedValue({ name: "Luna" });
mockSendTeamMessage.mockResolvedValue(undefined);
await caller.admin.dismissReport({ reportId: "r2" });
const body = mockSendTeamMessage.mock.calls[0][2] as string;
expect(body).toContain("das Profil von Luna");
expect(body).not.toContain("Begründung");
});
it("sends nothing when notifyReporter is false", 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: null });
await caller.admin.dismissReport({ reportId: "r1", notifyReporter: false });
expect(mockSendTeamMessage).not.toHaveBeenCalled();
});
it("still succeeds when the message fails", 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: null });
mockSendTeamMessage.mockRejectedValue(new Error("db down"));
vi.spyOn(console, "error").mockImplementation(() => {});
await expect(caller.admin.dismissReport({ reportId: "r1" })).resolves.toEqual({ success: true });
});
});
describe("admin.deleteReportedPost reporter feedback", () => {
it("deletes the post's reports and thanks every reporter once", 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" },
{ reporterPetId: "reporter-2" },
{ reporterPetId: "reporter-1" },
]);
mockSendTeamMessage.mockResolvedValue(undefined);
await caller.admin.deleteReportedPost({ postId: "post-1", reason: "Gewaltdarstellung" });
expect(mockPrisma.report.deleteMany).toHaveBeenCalledWith({ where: { targetPostId: "post-1" } });
expect(mockSendTeamMessage.mock.calls.map((c) => c[1])).toEqual(["reporter-1", "reporter-2"]);
const body = mockSendTeamMessage.mock.calls[0][2] as string;
expect(body).toContain("den Beitrag von Bubi");
expect(body).toContain("Inhalt entfernt");
// Die interne Lösch-Begründung geht nicht an den Melder.
expect(body).not.toContain("Gewaltdarstellung");
});
it("keeps notifying the other reporters when one message fails", 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" }, { reporterPetId: "reporter-2" }]);
mockSendTeamMessage.mockRejectedValueOnce(new Error("fail")).mockResolvedValueOnce(undefined);
vi.spyOn(console, "error").mockImplementation(() => {});
await expect(caller.admin.deleteReportedPost({ postId: "post-1" })).resolves.toEqual({ success: true });
expect(mockSendTeamMessage).toHaveBeenCalledTimes(2);
});
it("sends nothing when notifyReporters is false", 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" }]);
await caller.admin.deleteReportedPost({ postId: "post-1", notifyReporters: false });
expect(mockSendTeamMessage).not.toHaveBeenCalled();
});
});
+2 -1
View File
@@ -198,7 +198,7 @@ describe("admin transactional moderation actions", () => {
mockPrisma.report.delete.mockResolvedValue({ id: "report-1" });
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
const result = await caller.admin.dismissReport({ reportId: "report-1" });
const result = await caller.admin.dismissReport({ reportId: "report-1", notifyReporter: false });
expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Array));
expect(mockPrisma.report.delete).toHaveBeenCalledWith({ where: { id: "report-1" } });
@@ -211,6 +211,7 @@ describe("admin transactional moderation actions", () => {
mockPrisma.post.findUnique.mockResolvedValue({ repost: null });
mockPrisma.post.delete.mockResolvedValue({ id: "post-1" });
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
mockPrisma.report.findMany.mockResolvedValue([]);
const result = await caller.admin.deleteReportedPost({ postId: "post-1" });
+2
View File
@@ -143,6 +143,7 @@ interface MockReportClient {
findMany: MockFn;
findUnique: MockFn;
delete: MockFn;
deleteMany: MockFn;
count: MockFn;
}
@@ -536,6 +537,7 @@ export function createMockPrisma(): MockPrismaClient {
findMany: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
count: vi.fn(),
},
userBan: {
+42 -6
View File
@@ -8,6 +8,8 @@ import { BlurImage } from "@/components/ui/blur-image";
import { Flag, Trash2, X, Loader2, CheckCheck, MessageCircle, Heart, ImageOff } 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 { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
@@ -37,6 +39,9 @@ export default function AdminReportsPage() {
const qc = useQueryClient();
const [action, setAction] = useState<ReportAction | null>(null);
const [reason, setReason] = useState("");
// found.md: Melder bekommt eine Team-Nachricht (Begründung bzw. Dank).
const [dismissMessage, setDismissMessage] = useState("");
const [notifyReporter, setNotifyReporter] = useState(true);
const [detailPostId, setDetailPostId] = useState<string | null>(null);
const { data: reports = [], isLoading } = useQuery(trpc.admin.listReports.queryOptions());
@@ -72,14 +77,20 @@ export default function AdminReportsPage() {
function closeDialog() {
setAction(null);
setReason("");
setDismissMessage("");
setNotifyReporter(true);
}
function confirm() {
if (!action) return;
if (action.kind === "dismiss") {
dismiss.mutate({ reportId: action.reportId });
dismiss.mutate({
reportId: action.reportId,
notifyReporter,
message: dismissMessage.trim() || undefined,
});
} else {
deletePost.mutate({ postId: action.postId, reason: reason || undefined });
deletePost.mutate({ postId: action.postId, reason: reason || undefined, notifyReporters: notifyReporter });
}
}
@@ -269,9 +280,20 @@ export default function AdminReportsPage() {
</DialogHeader>
{action?.kind === "dismiss" ? (
<p className="text-sm text-zinc-400">
Den Report als unbegründet schließen? Der Inhalt bleibt bestehen.
</p>
<>
<p className="text-sm text-zinc-400">
Den Report als unbegründet schließen? Der Inhalt bleibt bestehen.
</p>
<Textarea
placeholder="Begründung für den Melder (optional), z. B. warum kein Verstoß vorliegt"
value={dismissMessage}
onChange={(e) => setDismissMessage(e.target.value)}
maxLength={500}
rows={3}
disabled={!notifyReporter}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
</>
) : (
<>
<p className="text-sm text-zinc-400">
@@ -279,7 +301,7 @@ export default function AdminReportsPage() {
ebenfalls geschlossen.
</p>
<Input
placeholder="Begründung (optional)"
placeholder="Interne Begründung (optional, nur im Moderations-Log)"
value={reason}
onChange={(e) => setReason(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
@@ -287,6 +309,20 @@ export default function AdminReportsPage() {
</>
)}
<label className="flex items-start gap-3 rounded-lg border border-zinc-800 px-3 py-2.5 text-sm">
<Switch checked={notifyReporter} onCheckedChange={setNotifyReporter} className="mt-0.5" />
<span>
<span className="text-zinc-100">
{action?.kind === "dismiss" ? "Melder benachrichtigen" : "Allen Meldern danken"}
</span>
<span className="block text-xs text-zinc-500">
{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>
</label>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={closeDialog}>
Abbrechen
+34
View File
@@ -0,0 +1,34 @@
/**
* Team-Nachrichten an den Melder, nachdem die Moderation einen Report
* bearbeitet hat (found.md: Rückmeldung bei abgewiesener Meldung mit
* Begründung, Dank bei bestätigter Löschung). Verschickt per
* sendTeamMessage() — Deutsch wie alle übrigen Team-Nachrichten.
*
* Bewusst ohne Details zur Gegenseite außer dem Tiernamen, den der Melder
* ohnehin kennt; die interne Lösch-Begründung (ModerationLog) geht nicht raus.
*/
const SIGNATURE = "Bei Fragen antworte einfach auf diese Nachricht. Dein PawFeed-Team";
/** „den Beitrag von Bubi“ / „das Profil von Bubi“ — Fallback ohne Namen, falls das Ziel nicht mehr existiert. */
export function describeReportTarget(kind: "post" | "pet", targetPetName: string | null): string {
const noun = kind === "post" ? "den Beitrag" : "das Profil";
return targetPetName ? `${noun} von ${targetPetName}` : noun;
}
export function reportDismissedMessage(target: string, reason: string): string {
const trimmedReason = reason.trim();
const reasonPart = trimmedReason ? `\n\nBegründung: ${trimmedReason}` : "";
return (
`Hallo, danke für deine Meldung zu ${target}. Wir haben sie geprüft und ` +
`keinen Verstoß gegen unsere Community-Richtlinien festgestellt, deshalb bleibt der Inhalt online.` +
`${reasonPart}\n\n${SIGNATURE}`
);
}
export function reportUpheldMessage(target: string): string {
return (
`Hallo, danke für deine Meldung zu ${target}. Wir haben sie geprüft und den Inhalt entfernt. ` +
`Danke, dass du hilfst, PawFeed sicher und freundlich zu halten!\n\n${SIGNATURE}`
);
}
+63 -4
View File
@@ -20,6 +20,7 @@ import { sendMail } from "@/lib/mail";
import { buildGreeting, renderBrandedMailHtml } from "@/lib/mail-template";
import { deleteOwnerAccount } from "@/lib/delete-account";
import { ensureTeamPet, sendTeamMessage } from "@/lib/team-pet";
import { describeReportTarget, reportDismissedMessage, reportUpheldMessage } from "@/lib/report-feedback";
import { matchesWordBlacklist, matchesLinkBlacklist } from "@/lib/blacklist";
import { getDailyVisitCount, getUniqueVisitorCount, lastNDays, daysInRange } from "@/lib/visit-tracking";
import { isMaintenanceModeEnabled, setMaintenanceMode } from "@/lib/maintenance";
@@ -1289,7 +1290,14 @@ export const adminRouter = router({
}),
dismissReport: protectedProcedure
.input(z.object({ reportId: z.string().min(1) }))
.input(
z.object({
reportId: z.string().min(1),
// found.md: der Melder bekommt eine Team-Nachricht mit Begründung.
notifyReporter: z.boolean().default(true),
message: z.string().max(500).optional(),
})
)
.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 } });
@@ -1301,29 +1309,69 @@ export const adminRouter = router({
action: "DISMISS_REPORT",
targetType: "REPORT",
targetId: input.reportId,
reason: input.message?.trim() || null,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
// Best-effort — the dismissal is already persisted; a failed message
// 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 target = describeReportTarget(report.targetPostId ? "post" : "pet", targetPetName);
await sendTeamMessage(ctx.prisma, report.reporterPetId, reportDismissedMessage(target, input.message ?? ""));
} catch (err) {
console.error("[admin] report dismissal notification failed", err);
}
}
return { success: true };
}),
deleteReportedPost: protectedProcedure
.input(z.object({ postId: z.string().min(1), reason: z.string().max(500).optional() }))
.input(
z.object({
postId: z.string().min(1),
reason: z.string().max(500).optional(),
// found.md: alle Melder dieses Beitrags bekommen eine Dankes-Nachricht.
notifyReporters: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
// Same repostCount fix as deletePost above.
const post = await ctx.prisma.post.findUnique({
where: { id: input.postId },
select: { repost: { select: { originalPostId: true } } },
select: { repost: { select: { originalPostId: true } }, pet: { select: { name: true } } },
});
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
// Delete the post (cascades to reports, comments, reactions via Prisma schema)
// Report.targetPostId is a plain string, not a relation — deleting the
// post does NOT cascade to its reports, they'd linger in the queue
// pointing at nothing. Collect the reporters first, delete the reports
// in the same transaction.
const reports = await ctx.prisma.report.findMany({
where: { targetPostId: input.postId },
select: { reporterPetId: true },
});
// Comments and reactions cascade via the Prisma schema; reports don't (see above).
await ctx.prisma.$transaction([
ctx.prisma.report.deleteMany({ where: { targetPostId: input.postId } }),
ctx.prisma.post.delete({ where: { id: input.postId } }),
...(post.repost
? [
@@ -1345,6 +1393,17 @@ export const adminRouter = router({
},
}),
]);
if (input.notifyReporters) {
const message = reportUpheldMessage(describeReportTarget("post", post.pet?.name ?? null));
const reporterPetIds = [...new Set(reports.map((r) => r.reporterPetId))];
// Sequential (ensureTeamPet must not race with itself) and
// best-effort: one failing recipient must not stop the others.
for (const petId of reporterPetIds) {
await sendTeamMessage(ctx.prisma, petId, message).catch((err) =>
console.error("[admin] report upheld notification failed", petId, err)
);
}
}
return { success: true };
}),