diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3157474..7117920 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -797,7 +797,7 @@ model AdRepost { model ModerationLog { id String @id @default(cuid()) - action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST + action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET | SYSTEM targetId String reason String? @db.VarChar(500) diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index 08e38a5..a14e97c 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -720,4 +720,50 @@ describe("admin.createBroadcast / admin.listBroadcasts (Gitea #10 Phase 1)", () }), ]); }); + + it("deleteBroadcast wraps delete + log in a transaction and requires a reason for a SUPER_ADMIN caller", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.broadcast.findUnique.mockResolvedValue({ id: "b1", title: "Wartung" }); + mockPrisma.broadcast.delete.mockResolvedValue({ id: "b1" }); + mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); + + const result = await caller.admin.deleteBroadcast({ broadcastId: "b1", reason: "Falscher Titel" }); + + expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Array)); + expect(mockPrisma.broadcast.delete).toHaveBeenCalledWith({ where: { id: "b1" } }); + expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: "DELETE_BROADCAST", + targetType: "SYSTEM", + targetId: "b1", + reason: "Falscher Titel", + }), + }) + ); + expect(result).toEqual({ success: true }); + }); + + it("deleteBroadcast throws FORBIDDEN for a MODERATOR (below SUPER_ADMIN)", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "someone-else"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.adminRole.findUnique.mockResolvedValue({ role: "MODERATOR" }); + + await expect( + caller.admin.deleteBroadcast({ broadcastId: "b1", reason: "x" }) + ).rejects.toThrow(TRPCError); + expect(mockPrisma.broadcast.delete).not.toHaveBeenCalled(); + }); + + it("deleteBroadcast throws NOT_FOUND for a missing broadcast", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.broadcast.findUnique.mockResolvedValue(null); + + await expect( + caller.admin.deleteBroadcast({ broadcastId: "missing", reason: "x" }) + ).rejects.toThrow(TRPCError); + expect(mockPrisma.broadcast.delete).not.toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index c273718..9cd9c9d 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -267,6 +267,8 @@ interface MockDataExportTokenClient { interface MockBroadcastClient { create: MockFn; findMany: MockFn; + findUnique: MockFn; + delete: MockFn; } interface MockBroadcastReceiptClient { @@ -540,6 +542,8 @@ export function createMockPrisma(): MockPrismaClient { broadcast: { create: vi.fn(), findMany: vi.fn(), + findUnique: vi.fn(), + delete: vi.fn(), }, broadcastReceipt: { upsert: vi.fn(), diff --git a/src/app/p/[secret]/broadcasts/page.tsx b/src/app/p/[secret]/broadcasts/page.tsx index 1cdce7f..f55c2b8 100644 --- a/src/app/p/[secret]/broadcasts/page.tsx +++ b/src/app/p/[secret]/broadcasts/page.tsx @@ -3,10 +3,11 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; -import { Send, Loader2 } from "lucide-react"; +import { Send, Loader2, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { toast } from "sonner"; function formatDate(d: string | Date) { @@ -24,6 +25,8 @@ export default function AdminBroadcastsPage() { const qc = useQueryClient(); const [title, setTitle] = useState(""); const [body, setBody] = useState(""); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null); + const [deleteReason, setDeleteReason] = useState(""); const { data: broadcasts = [], isLoading } = useQuery(trpc.admin.listBroadcasts.queryOptions()); @@ -39,11 +42,28 @@ export default function AdminBroadcastsPage() { }) ); + const deleteMutation = useMutation( + trpc.admin.deleteBroadcast.mutationOptions({ + onSuccess: () => { + void qc.invalidateQueries({ queryKey: trpc.admin.listBroadcasts.queryKey() }); + toast.success("Mitteilung gelöscht"); + setDeleteTarget(null); + setDeleteReason(""); + }, + onError: () => toast.error("Löschen fehlgeschlagen"), + }) + ); + const handleSend = () => { if (!title.trim() || !body.trim()) return; createMutation.mutate({ title: title.trim(), body: body.trim() }); }; + const handleConfirmDelete = () => { + if (!deleteTarget || !deleteReason.trim()) return; + deleteMutation.mutate({ broadcastId: deleteTarget.id, reason: deleteReason.trim() }); + }; + return (
{b.body}
@@ -115,6 +145,42 @@ export default function AdminBroadcastsPage() {