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:
1 parent
1965179d36
commit
5870d8448a
5 files changed
+147
-3
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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">
|
||||
"{deleteTarget?.title}" 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>
|
||||
);
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
Reference in new issue
Block a user