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:
1 parent
01459c7e0e
commit
f51b158989
6 files changed
+296
-11
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
|
||||
Reference in new issue
Block a user