feat(admin): add deleteBroadcast with required reason for the audit trail

Broadcasts had no delete path (Phase 1 gap) — had to remove a test
message via a one-off script yesterday. New admin.deleteBroadcast
mutation mirrors hidePost/banOwner: SUPER_ADMIN-gated (same bar as
createBroadcast), reason required (min 1 char), wraps delete + a
DELETE_BROADCAST ModerationLog entry in one transaction.
BroadcastReceipt rows cascade via the existing onDelete: Cascade.

Frontend: trash icon per history item opens a confirm dialog with a
required reason input, mirroring the posts admin page's delete-confirm
pattern.
This commit is contained in:
admin committed 2026-08-22 15:48:39 +02:00
1 parent 1965179d36
commit 5870d8448a
5 files changed
+147 -3

No files matched your search

+1 -1
View File
@@ -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)
+46
View File
@@ -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();
});
});
+4
View File
@@ -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(),
+68 -2
View File
@@ -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 (
<div className="space-y-6">
<div>
@@ -104,7 +124,17 @@ export default function AdminBroadcastsPage() {
<div key={b.id} className="rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-sm font-medium text-zinc-100">{b.title}</span>
<span className="text-xs text-zinc-600 shrink-0">{formatDate(b.createdAt)}</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-zinc-600">{formatDate(b.createdAt)}</span>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
onClick={() => setDeleteTarget({ id: b.id, title: b.title })}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<p className="text-sm text-zinc-400 whitespace-pre-wrap">{b.body}</p>
<p className="text-xs text-zinc-600 mt-2">
@@ -115,6 +145,42 @@ export default function AdminBroadcastsPage() {
</div>
)}
</div>
{/* Confirm delete dialog — reason required for the audit trail (ModerationLog) */}
<Dialog
open={!!deleteTarget}
onOpenChange={(o) => { if (!o) { setDeleteTarget(null); setDeleteReason(""); } }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogHeader>
<DialogTitle>Mitteilung löschen</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
&quot;{deleteTarget?.title}&quot; wird für alle Owner entfernt. Diese Aktion kann nicht
rückgängig gemacht werden.
</p>
<Input
placeholder="Begründung (Pflichtfeld, erscheint im Audit Log)"
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
maxLength={500}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteTarget(null)}>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={deleteMutation.isPending || !deleteReason.trim()}
>
{deleteMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Löschen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+28
View File
@@ -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([