"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 } 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); } 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" }; const 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 }; }