feat(web): delete reminder server action wired to detail page

This commit is contained in:
yiekheng 2026-05-09 23:46:23 +08:00
parent 83a19d4800
commit 6916f5a0ed
3 changed files with 56 additions and 29 deletions

View File

@ -0,0 +1,42 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { eq } from "drizzle-orm";
import { reminders } from "@cmbot/db";
import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator";
import { checkRateLimit } from "@/lib/rate-limit";
async function rateLimit(key: string) {
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "unknown";
const r = await checkRateLimit(`${key}:${ip}`, { max: 30, windowSec: 10 });
if (r.limited) throw new Error("Too many requests");
}
export async function deleteReminderAction(formData: FormData): Promise<void> {
await rateLimit("delete-reminder");
const reminderId = formData.get("reminderId");
if (typeof reminderId !== "string") return;
const op = await getSeededOperator();
const reminder = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),
});
if (!reminder) return;
// Verify ownership via the account
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, reminder.accountId), eq(a.operatorId, op.id)),
});
if (!account) return;
// Cascading FKs (reminder_runs + reminder_targets + reminder_messages) clean up.
// pg-boss job for this reminder will fire and find the row gone (soft cancel).
await db.delete(reminders).where(eq(reminders.id, reminderId));
revalidatePath("/reminders" as any);
redirect("/reminders" as any);
}

View File

@ -12,24 +12,14 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { deleteReminderAction } from "@/actions/reminders";
interface DeleteDialogProps {
deleteAction: () => Promise<void>;
reminderId: string;
}
export function DeleteDialog({ deleteAction }: DeleteDialogProps) {
export function DeleteDialog({ reminderId }: DeleteDialogProps) {
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
async function handleConfirm() {
setPending(true);
try {
await deleteAction();
} finally {
setPending(false);
setOpen(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
@ -48,14 +38,16 @@ export function DeleteDialog({ deleteAction }: DeleteDialogProps) {
</DialogDescription>
</DialogHeader>
<DialogFooter>
<form action={deleteReminderAction} className="flex gap-2">
<input type="hidden" name="reminderId" value={reminderId} />
<Button
variant="destructive"
size="sm"
disabled={pending}
onClick={handleConfirm}
type="submit"
>
{pending ? "Deleting…" : "Delete"}
Delete
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -73,13 +73,6 @@ function StatusPill({ status }: { status: string }) {
);
}
// ---------------------------------------------------------------------------
// Server action (no-op placeholder — real delete wired in Task 19)
// ---------------------------------------------------------------------------
async function _deleteReminderStub() {
"use server";
// wired in Task 19
}
// ---------------------------------------------------------------------------
// Page
@ -242,7 +235,7 @@ export default async function ReminderDetailPage({ params }: Props) {
{/* Action footer */}
<div className="flex items-center justify-end pt-2 border-t">
<DeleteDialog deleteAction={_deleteReminderStub} />
<DeleteDialog reminderId={reminder.id} />
</div>
</div>
);