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 (
@@ -104,7 +124,17 @@ export default function AdminBroadcastsPage() {
{b.title} - {formatDate(b.createdAt)} +
+ {formatDate(b.createdAt)} + +

{b.body}

@@ -115,6 +145,42 @@ export default function AdminBroadcastsPage() {

)}
+ + {/* Confirm delete dialog — reason required for the audit trail (ModerationLog) */} + { if (!o) { setDeleteTarget(null); setDeleteReason(""); } }} + > + + + Mitteilung löschen + +

+ "{deleteTarget?.title}" wird für alle Owner entfernt. Diese Aktion kann nicht + rückgängig gemacht werden. +

+ setDeleteReason(e.target.value)} + maxLength={500} + className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500" + /> + + + + +
+
); } diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index beeda41..f9b073a 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -1781,6 +1781,34 @@ export const adminRouter = router({ return broadcast; }), + // Destructive — a broadcast may already be showing in owners' banners, so + // deletion always requires a reason for the audit trail, same bar as + // hidePost/banOwner. SUPER_ADMIN-gated to match createBroadcast. + deleteBroadcast: protectedProcedure + .input(z.object({ broadcastId: z.string().min(1), reason: z.string().min(1).max(500) })) + .mutation(async ({ ctx, input }) => { + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN); + const broadcast = await ctx.prisma.broadcast.findUnique({ where: { id: input.broadcastId } }); + if (!broadcast) throw new TRPCError({ code: "NOT_FOUND" }); + + await ctx.prisma.$transaction([ + // BroadcastReceipt rows cascade (schema.prisma: onDelete: Cascade). + ctx.prisma.broadcast.delete({ where: { id: input.broadcastId } }), + ctx.prisma.moderationLog.create({ + data: { + action: "DELETE_BROADCAST", + targetType: "SYSTEM", + targetId: input.broadcastId, + reason: input.reason, + moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, + }, + }), + ]); + return { success: true }; + }), + listBroadcasts: protectedProcedure.query(async ({ ctx }) => { await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); const [broadcasts, totalOwners] = await Promise.all([