"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 } 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"; 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); } const createReminderSchema = z .object({ accountId: z.string().uuid(), groupIds: z.array(z.string().uuid()), 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), }) .refine((d) => Boolean(d.text?.trim()) || Boolean(d.mediaId), { message: "Add a message or attach a file", path: ["text"], }); 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, text, mediaId, caption, scheduledAtIso, rrule, timezone } = 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" }; } const reminderId = await db.transaction(async (tx) => { const [rem] = await tx .insert(reminders) .values({ accountId, name: (text ?? caption ?? "Reminder").slice(0, 50), scheduleKind: rrule ? "recurring" : "one_off", scheduledAt, rrule: rrule ?? null, timezone, 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 })), ); } if (text && !mediaId) { await tx.insert(reminderMessages).values({ reminderId: rem!.id, position: 0, kind: "text", textContent: text, mediaId: null, }); } else if (mediaId) { await tx.insert(reminderMessages).values({ reminderId: rem!.id, position: 0, kind: "media", textContent: caption ?? text ?? null, 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, text, mediaId, caption, scheduledAtIso, rrule, timezone, } = 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 { 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" }; } } 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" }; } await db.transaction(async (tx) => { await tx .update(reminders) .set({ accountId, name: (text ?? caption ?? "Reminder").slice(0, 50), scheduleKind: rrule ? "recurring" : "one_off", scheduledAt, rrule: rrule ?? null, timezone, status: "active", 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)); if (text && !mediaId) { await tx.insert(reminderMessages).values({ reminderId, position: 0, kind: "text", textContent: text, mediaId: null, }); } else if (mediaId) { await tx.insert(reminderMessages).values({ reminderId, position: 0, kind: "media", textContent: caption ?? text ?? null, 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 }; }