diff --git a/apps/web/src/actions/reminders.ts b/apps/web/src/actions/reminders.ts index ac4f9f1..de188ec 100644 --- a/apps/web/src/actions/reminders.ts +++ b/apps/web/src/actions/reminders.ts @@ -128,6 +128,80 @@ export async function restartReminderAction(formData: FormData): Promise { revalidatePath(`/reminders/${reminderId}` as any); } +/** + * Duplicate a reminder. Creates a new reminder with the same account, + * groups, and message parts. The copy starts \`paused\` and inherits + * the source's scheduledAt / rrule unchanged — the user can edit the + * schedule from the detail page and Restart when ready. + */ +export async function duplicateReminderAction(formData: FormData): Promise { + await rateLimit("duplicate-reminder"); + const reminderId = formData.get("reminderId"); + if (typeof reminderId !== "string") return; + const op = await getSeededOperator(); + + const source = await db.query.reminders.findFirst({ + where: (r, { eq }) => eq(r.id, reminderId), + }); + if (!source) return; + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => + and(eq(a.id, source.accountId), eq(a.operatorId, op.id)), + }); + if (!account) return; + + const sourceTargets = await db.query.reminderTargets.findMany({ + where: (t, { eq }) => eq(t.reminderId, reminderId), + }); + const sourceMessages = await db.query.reminderMessages.findMany({ + where: (m, { eq }) => eq(m.reminderId, reminderId), + }); + + const newId = await db.transaction(async (tx) => { + const [rem] = await tx + .insert(reminders) + .values({ + accountId: source.accountId, + name: `${source.name} (copy)`.slice(0, 60), + scheduleKind: source.scheduleKind, + scheduledAt: source.scheduledAt, + rrule: source.rrule, + timezone: source.timezone, + // Start paused so the copy doesn't fire on top of the original + // — the user picks a new time / reactivates from the detail page. + status: "paused", + createdBy: op.id, + }) + .returning({ id: reminders.id }); + + if (sourceTargets.length > 0) { + await tx.insert(reminderTargets).values( + sourceTargets.map((t) => ({ + reminderId: rem!.id, + groupId: t.groupId, + position: t.position, + })), + ); + } + if (sourceMessages.length > 0) { + await tx.insert(reminderMessages).values( + sourceMessages.map((m) => ({ + reminderId: rem!.id, + position: m.position, + kind: m.kind, + textContent: m.textContent, + mediaId: m.mediaId, + })), + ); + } + return rem!.id; + }); + + revalidatePath("/reminders"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + redirect(`/reminders/${newId}` as any); +} + const createReminderSchema = z .object({ accountId: z.string().uuid(), diff --git a/apps/web/src/app/reminders/[id]/actions-bar.test.tsx b/apps/web/src/app/reminders/[id]/actions-bar.test.tsx index 4c287df..b453360 100644 --- a/apps/web/src/app/reminders/[id]/actions-bar.test.tsx +++ b/apps/web/src/app/reminders/[id]/actions-bar.test.tsx @@ -6,6 +6,7 @@ vi.mock("@/actions/reminders", () => ({ pauseReminderAction: vi.fn(), restartReminderAction: vi.fn(), deleteReminderAction: vi.fn(), + duplicateReminderAction: vi.fn(), })); // Make Dialog primitives transparent so we can grep the underlying tree. diff --git a/apps/web/src/app/reminders/[id]/actions-bar.tsx b/apps/web/src/app/reminders/[id]/actions-bar.tsx index c5111bd..2cf9e20 100644 --- a/apps/web/src/app/reminders/[id]/actions-bar.tsx +++ b/apps/web/src/app/reminders/[id]/actions-bar.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { AlertCircleIcon, + CopyIcon, Loader2Icon, PauseIcon, PlayIcon, @@ -21,6 +22,7 @@ import { } from "@/components/ui/dialog"; import { deleteReminderAction, + duplicateReminderAction, pauseReminderAction, restartReminderAction, } from "@/actions/reminders"; @@ -47,7 +49,7 @@ export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) const canRestart = status === "paused" || status === "ended"; return ( -
+
{canPause && ( )} + {/* Duplicate — always available, non-destructive */} + } + accentBg="bg-sky-500/10" + accentRing="hover:ring-sky-500/30" + dialogTitle="Duplicate this reminder?" + dialogBody="A paused copy is created with the same account, groups, message and schedule. Edit it and Restart when you're ready." + confirmLabel="Yes, duplicate" + confirmVariant="default" + confirmIcon={} + action={duplicateReminderAction} + reminderId={reminderId} + /> + {/* Delete is always available */} Last connected{" "} - {account.lastConnectedAt.toLocaleDateString("en-MY", { + {account.lastConnectedAt.toLocaleString("en-MY", { timeZone: "Asia/Kuala_Lumpur", year: "numeric", month: "short", day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: true, })}