"use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { headers } from "next/headers"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { DateTime } from "luxon"; import { reminders, reminderTargets, reminderMessages, reminderRuns, reminderRunTargets, } from "@cmbot/db"; import { DEFAULT_TIMEZONE, isCronRule, nextOccurrence, validateMinInterval } from "@cmbot/shared"; import { db } from "@/lib/db"; import { getSeededOperator } from "@/lib/operator"; import { checkRateLimit } from "@/lib/rate-limit"; import { pgNotifyBot } from "@/lib/notify"; import { validateUpdateScheduledAt } from "@/lib/reminder-update"; import { resolveReminderName } from "@/lib/reminder-name"; 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 { 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); } /** * Resolve and verify the reminder owned by the seeded operator. Returns * null if the reminder doesn't exist or belongs to a different account. */ async function loadOwnedReminder(reminderId: string) { const op = await getSeededOperator(); const reminder = await db.query.reminders.findFirst({ where: (r, { eq }) => eq(r.id, reminderId), }); if (!reminder) return null; 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 null; return reminder; } /** * Pause an active reminder. The pg-boss job stays armed (we don't have * a hard cancel) but `fireReminder` exits early when status !== "active". */ export async function pauseReminderAction(formData: FormData): Promise { await rateLimit("pause-reminder"); const reminderId = formData.get("reminderId"); if (typeof reminderId !== "string") return; const reminder = await loadOwnedReminder(reminderId); if (!reminder) return; if (reminder.status !== "active") return; // already not running await db .update(reminders) .set({ status: "paused", updatedAt: new Date() }) .where(eq(reminders.id, reminderId)); revalidatePath("/reminders" as any); revalidatePath(`/reminders/${reminderId}` as any); } /** * Restart a paused or ended reminder. For a one-off whose scheduledAt is * in the past, push it to "now + 1 minute" so it fires soon. For a * recurring reminder, compute the next occurrence from the RRULE. * Either way the row flips back to `active` and the pg-boss job is * re-armed. */ export async function restartReminderAction(formData: FormData): Promise { await rateLimit("restart-reminder"); const reminderId = formData.get("reminderId"); if (typeof reminderId !== "string") return; const reminder = await loadOwnedReminder(reminderId); if (!reminder) return; let nextFire: Date | null = null; const now = new Date(); if (reminder.scheduleKind === "recurring" && reminder.rrule) { const { nextOccurrence } = await import("@cmbot/shared"); nextFire = nextOccurrence(reminder.rrule, reminder.timezone, now); } else if (reminder.scheduledAt && reminder.scheduledAt.getTime() > Date.now() + 30_000) { // The original time is still in the future and far enough away to // be useful — keep it. nextFire = reminder.scheduledAt; } else { nextFire = new Date(Date.now() + 60_000); } if (!nextFire) return; await db .update(reminders) .set({ status: "active", scheduledAt: nextFire, updatedAt: now, }) .where(eq(reminders.id, reminderId)); await pgNotifyBot({ type: "reminder.schedule", reminderId, scheduledAtIso: nextFire.toISOString(), }); revalidatePath("/reminders" as any); 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); } // A single deliverable message part. See lib/reminder-messages.ts for // the wire format the wizard URL uses. const messagePartSchema = z .object({ kind: z.enum(["text", "media"]), textContent: z.string().nullable().optional(), mediaId: z.string().uuid().nullable().optional(), }) .refine( (m) => m.kind === "text" ? Boolean(m.textContent && m.textContent.trim()) : Boolean(m.mediaId), { message: "Each message part needs text or a media file" }, ); const createReminderSchema = z .object({ accountId: z.string().uuid(), groupIds: z.array(z.string().uuid()), // The new shape — caller passes one or more MessageParts in send order. // Optional/nullable here so the legacy fallback below can be used by // older URL bookmarks; the refine() guarantees we end up with at // least one valid message either way. messages: z.array(messagePartSchema).optional(), // User-supplied label shown in the list / detail page header. // Required: every reminder must carry a non-empty name. The // resolver still clamps to REMINDER_NAME_MAX so the DB column // never has to reject the row. The legacy auto-derive from the // first message part is kept as a fallback ONLY for legacy // bookmarked URLs (where the create form was submitted before // the field was added) — new submits always carry a name. name: z.string().trim().min(1, "Give the reminder a name").max(60), // Legacy single-message fields. Still accepted so bookmarked // /reminders/new URLs don't 400 after the migration. The action body // collapses these into `messages` before doing any work. text: z.string().nullable().optional(), mediaId: z.string().uuid().nullable().optional(), caption: z.string().nullable().optional(), // `.datetime({ offset: true })` accepts both UTC `Z` and zoned offsets // like `+08:00` (luxon's `toISO()` produces the offset form). scheduledAtIso: z.string().datetime({ offset: true }), rrule: z.string().nullable().optional(), timezone: z.string().default(DEFAULT_TIMEZONE), // Delivery window in the operator's timezone. End hour will gate // the runtime fan-out in a later phase; start is documented but // not yet enforced. Optional in the input shape for backward // compatibility — the action body falls back to 6/18. deliveryWindowStartHour: z.number().int().min(0).max(24).optional(), deliveryWindowEndHour: z.number().int().min(0).max(24).optional(), }) .refine( (d) => (d.messages && d.messages.length > 0) || Boolean(d.text?.trim()) || Boolean(d.mediaId), { message: "Add a message or attach a file", path: ["messages"], }, ) .refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 24), { message: "Delivery window start must be earlier than end", path: ["deliveryWindowStartHour"], }); /** Resolve the schema's union of new + legacy fields into a flat list. */ function resolveMessageParts(parsed: z.infer): Array<{ kind: "text" | "media"; textContent: string | null; mediaId: string | null; }> { if (parsed.messages && parsed.messages.length > 0) { return parsed.messages.map((m) => ({ kind: m.kind, textContent: m.textContent ?? null, mediaId: m.mediaId ?? null, })); } // Legacy: fold (text, mediaId, caption) into one part. if (parsed.mediaId) { return [ { kind: "media", mediaId: parsed.mediaId, textContent: parsed.caption?.trim() || parsed.text?.trim() || null, }, ]; } return [ { kind: "text", textContent: parsed.text!, mediaId: null, }, ]; } export type CreateReminderResult = | { ok: true; reminderId: string } | { ok: false; error: string }; export async function createReminderAction( input: z.infer, ): Promise { await rateLimit("create-reminder"); const parsed = createReminderSchema.safeParse(input); if (!parsed.success) { return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; } const { accountId, groupIds, scheduledAtIso, rrule, timezone, } = parsed.data; const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6; // 24 = "no deadline" (off). The wizard sends 24 explicitly when the // operator hasn't ticked the optional "Pause sending by" checkbox; // fall back to 24 here so legacy payloads / direct API calls don't // accidentally enable the deadline at 6pm. const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24; const parts = resolveMessageParts(parsed.data); const op = await getSeededOperator(); const account = await db.query.whatsappAccounts.findFirst({ where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), }); if (!account) return { ok: false, error: "Account not yours" }; // Resolve the first-fire timestamp. Cron rules ignore the user- // supplied date+time (the form sends a placeholder) and let the cron // expression define when the reminder runs first. let scheduledAt: Date; if (rrule && isCronRule(rrule)) { const minCheck = validateMinInterval(rrule, timezone); if (!minCheck.ok) return { ok: false, error: minCheck.reason }; const firstFire = nextOccurrence(rrule, timezone, new Date()); if (!firstFire) { return { ok: false, error: "Cron expression doesn't produce any future fire times" }; } scheduledAt = firstFire; } else { scheduledAt = DateTime.fromISO(scheduledAtIso, { zone: timezone }).toJSDate(); if (Number.isNaN(scheduledAt.getTime())) { return { ok: false, error: "Invalid date" }; } if (scheduledAt.getTime() <= Date.now()) { return { ok: false, error: "Time is in the past" }; } } // Verify all groups belong to this account const groups = await db.query.whatsappGroups.findMany({ where: (g, { eq, inArray, and }) => and(eq(g.accountId, accountId), inArray(g.id, groupIds)), }); if (groups.length !== groupIds.length) { return { ok: false, error: "One or more groups don't belong to this account" }; } // User-supplied name wins. If they didn't supply one, derive from // the first text-bearing part (text body or caption). Falls back to // the literal "Reminder" if every part is media-without-caption. const reminderName = resolveReminderName(parsed.data.name, parts); const reminderId = await db.transaction(async (tx) => { const [rem] = await tx .insert(reminders) .values({ accountId, name: reminderName, scheduleKind: rrule ? "recurring" : "one_off", scheduledAt, rrule: rrule ?? null, timezone, deliveryWindowStartHour, deliveryWindowEndHour, status: "active", createdBy: op.id, }) .returning({ id: reminders.id }); if (groupIds.length > 0) { await tx.insert(reminderTargets).values( groupIds.map((groupId, position) => ({ reminderId: rem!.id, groupId, position })), ); } await tx.insert(reminderMessages).values( parts.map((p, position) => ({ reminderId: rem!.id, position, kind: p.kind, textContent: p.textContent, mediaId: p.mediaId, })), ); return rem!.id; }); // Schedule via the bot's IPC consumer (Postgres NOTIFY) await pgNotifyBot({ type: "reminder.schedule", reminderId, scheduledAtIso: scheduledAt.toISOString(), }); return { ok: true, reminderId }; } const updateReminderSchema = createReminderSchema.and( z.object({ reminderId: z.string().uuid() }), ); export type UpdateReminderResult = | { ok: true; reminderId: string } | { ok: false; error: string }; export async function updateReminderAction( input: z.infer, ): Promise { await rateLimit("update-reminder"); const parsed = updateReminderSchema.safeParse(input); if (!parsed.success) { return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; } const { reminderId, accountId, groupIds, scheduledAtIso, rrule, timezone, } = parsed.data; const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6; // 24 = "no deadline" (off). The wizard sends 24 explicitly when the // operator hasn't ticked the optional "Pause sending by" checkbox; // fall back to 24 here so legacy payloads / direct API calls don't // accidentally enable the deadline at 6pm. const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24; const parts = resolveMessageParts(parsed.data); const op = await getSeededOperator(); // Verify the reminder exists, the operator owns its account, and the // (possibly changed) target account is also theirs. const existing = await db.query.reminders.findFirst({ where: (r, { eq }) => eq(r.id, reminderId), }); if (!existing) return { ok: false, error: "Reminder not found" }; const ownerOfExisting = await db.query.whatsappAccounts.findFirst({ where: (a, { eq, and }) => and(eq(a.id, existing.accountId), eq(a.operatorId, op.id)), }); if (!ownerOfExisting) return { ok: false, error: "Reminder not yours" }; const targetAccount = await db.query.whatsappAccounts.findFirst({ where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), }); if (!targetAccount) return { ok: false, error: "Account not yours" }; let scheduledAt: Date; if (rrule && isCronRule(rrule)) { const minCheck = validateMinInterval(rrule, timezone); if (!minCheck.ok) return { ok: false, error: minCheck.reason }; const firstFire = nextOccurrence(rrule, timezone, new Date()); if (!firstFire) { return { ok: false, error: "Cron expression doesn't produce any future fire times" }; } scheduledAt = firstFire; } else { const validated = validateUpdateScheduledAt({ iso: scheduledAtIso, timezone, existingStatus: existing.status, existingScheduledAt: existing.scheduledAt, now: new Date(), }); if (!validated.ok) return { ok: false, error: validated.error }; scheduledAt = validated.scheduledAt; } const groups = await db.query.whatsappGroups.findMany({ where: (g, { eq, inArray, and }) => and(eq(g.accountId, accountId), inArray(g.id, groupIds)), }); if (groups.length !== groupIds.length) { return { ok: false, error: "One or more groups don't belong to this account" }; } const reminderName = resolveReminderName(parsed.data.name, parts); await db.transaction(async (tx) => { await tx .update(reminders) .set({ accountId, name: reminderName, scheduleKind: rrule ? "recurring" : "one_off", scheduledAt, rrule: rrule ?? null, timezone, deliveryWindowStartHour, deliveryWindowEndHour, // Preserve the lifecycle status. Editing fields shouldn't // implicitly re-activate a paused or ended reminder — the // user can use the explicit Restart action for that. status: existing.status, updatedAt: new Date(), }) .where(eq(reminders.id, reminderId)); // Replace targets and messages wholesale — simpler than diffing. await tx.delete(reminderTargets).where(eq(reminderTargets.reminderId, reminderId)); if (groupIds.length > 0) { await tx.insert(reminderTargets).values( groupIds.map((groupId, position) => ({ reminderId, groupId, position })), ); } await tx.delete(reminderMessages).where(eq(reminderMessages.reminderId, reminderId)); await tx.insert(reminderMessages).values( parts.map((p, position) => ({ reminderId, position, kind: p.kind, textContent: p.textContent, mediaId: p.mediaId, })), ); }); // Re-arm the pg-boss job at the new scheduled time. The handler uses // singletonKey=reminder: so this supersedes the prior arming. await pgNotifyBot({ type: "reminder.schedule", reminderId, scheduledAtIso: scheduledAt.toISOString(), }); revalidatePath("/reminders"); revalidatePath(`/reminders/${reminderId}`); return { ok: true, reminderId }; } // --------------------------------------------------------------------------- // Resume / cancel a paused run // --------------------------------------------------------------------------- const runIdSchema = z.object({ runId: z.string().uuid() }); export type ResumeReminderRunResult = { ok: true } | { ok: false; error: string }; /** * Re-enqueue a paused reminder run. The bot picks it up, attaches to the * existing run row, and only re-tries the rows still in `pending` state. * * Validates that the operator owns the underlying reminder + account * pair and that the run is actually in 'paused' state — anything else * is a no-op (so a stale UI button doesn't double-fire a run). */ export async function resumeReminderRunAction(input: { runId: string; }): Promise { const ip = (await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 }); if (rl.limited) { return { ok: false, error: "Too many requests. Try again later." }; } const op = await getSeededOperator(); const parsed = runIdSchema.safeParse(input); if (!parsed.success) { return { ok: false, error: "Invalid runId" }; } const run = await db.query.reminderRuns.findFirst({ where: (r, { eq: dEq }) => dEq(r.id, parsed.data.runId), }); if (!run || !run.reminderId) return { ok: false, error: "Run not found" }; const reminder = await db.query.reminders.findFirst({ where: (r, { eq: dEq }) => dEq(r.id, run.reminderId!), }); if (!reminder) return { ok: false, error: "Reminder not found" }; // Operator must own the account the reminder belongs to. const owned = await db.query.whatsappAccounts.findFirst({ where: (a, { eq: dEq, and: dAnd }) => dAnd(dEq(a.id, reminder.accountId), dEq(a.operatorId, op.id)), }); if (!owned) return { ok: false, error: "Run not yours" }; if (run.status !== "paused") { return { ok: false, error: `Cannot resume a ${run.status} run` }; } await pgNotifyBot({ type: "reminder.resume", reminderId: reminder.id, runId: run.id, }); revalidatePath("/activity"); revalidatePath(`/reminders/${reminder.id}`); return { ok: true }; } export type CancelReminderRunResult = { ok: true } | { ok: false; error: string }; /** * Permanently end a paused run. Remaining `pending` targets become * `skipped` with a clear "canceled by operator" reason; the run row * resolves to `partial`. The reminder lifecycle is lifted out of * 'paused' — recurring goes back to 'active' so the next occurrence * fires; one-off ends. */ export async function cancelReminderRunAction(input: { runId: string; }): Promise { const ip = (await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 }); if (rl.limited) { return { ok: false, error: "Too many requests. Try again later." }; } const op = await getSeededOperator(); const parsed = runIdSchema.safeParse(input); if (!parsed.success) { return { ok: false, error: "Invalid runId" }; } const run = await db.query.reminderRuns.findFirst({ where: (r, { eq: dEq }) => dEq(r.id, parsed.data.runId), }); if (!run || !run.reminderId) return { ok: false, error: "Run not found" }; const reminder = await db.query.reminders.findFirst({ where: (r, { eq: dEq }) => dEq(r.id, run.reminderId!), }); if (!reminder) return { ok: false, error: "Reminder not found" }; const owned = await db.query.whatsappAccounts.findFirst({ where: (a, { eq: dEq, and: dAnd }) => dAnd(dEq(a.id, reminder.accountId), dEq(a.operatorId, op.id)), }); if (!owned) return { ok: false, error: "Run not yours" }; if (run.status !== "paused") { return { ok: false, error: `Cannot cancel a ${run.status} run` }; } await db.transaction(async (tx) => { // Pending → skipped with a clear cause. await tx .update(reminderRunTargets) .set({ status: "skipped", error: "canceled by operator" }) .where(eq(reminderRunTargets.runId, run.id)); await tx .update(reminderRuns) .set({ status: "partial", errorSummary: "Canceled by operator before all groups received the message.", }) .where(eq(reminderRuns.id, run.id)); // Lift the reminder out of 'paused'. Recurring goes back to active // so the next occurrence can fire; one-off has no future occurrence. await tx .update(reminders) .set({ status: reminder.scheduleKind === "recurring" ? "active" : "inactive", updatedAt: new Date(), }) .where(eq(reminders.id, reminder.id)); }); revalidatePath("/activity"); revalidatePath(`/reminders/${reminder.id}`); return { ok: true }; }