"use server"; import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; import { eq, sql } from "drizzle-orm"; import { reminderRuns } from "@cmbot/db"; import { db } from "@/lib/db"; import { getSeededOperator } from "@/lib/operator"; import { checkRateLimit } from "@/lib/rate-limit"; async function rateLimit(key: string, opts: { max?: number; windowSec?: number } = {}) { 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: opts.max ?? 5, windowSec: opts.windowSec ?? 60, }); if (r.limited) throw new Error("Too many requests"); } /** * Verify the run belongs to the seeded operator (or is an orphan from a * deleted reminder, which the dashboard considers shared history). Returns * the run's id when ownership checks out, otherwise null. */ async function checkRunOwnership(runId: string): Promise { const op = await getSeededOperator(); const rows = await db.execute<{ id: string }>(sql` SELECT rr.id FROM reminder_runs rr LEFT JOIN reminders r ON r.id = rr.reminder_id LEFT JOIN whatsapp_accounts wa ON wa.id = r.account_id WHERE rr.id = ${runId} AND (wa.operator_id = ${op.id} OR r.id IS NULL) LIMIT 1 `); return rows.rows[0]?.id ?? null; } /** * Wipe the operator's reminder run history. Operators only see runs whose * underlying reminder is still owned by them PLUS orphan runs (whose * reminder was deleted) — the dashboard query mirrors this. We delete * both sets so "clear history" feels exhaustive. */ export async function clearHistoryAction(): Promise { await rateLimit("clear-history"); const op = await getSeededOperator(); await db.execute(sql` DELETE FROM ${reminderRuns} WHERE id IN ( SELECT rr.id FROM ${reminderRuns} rr LEFT JOIN reminders r ON r.id = rr.reminder_id LEFT JOIN whatsapp_accounts wa ON wa.id = r.account_id WHERE wa.operator_id = ${op.id} OR r.id IS NULL ) `); revalidatePath("/"); revalidatePath("/reminders"); } /** Soft-archive one run. Hidden from the default activity list afterwards. */ export async function archiveRunAction(formData: FormData): Promise { await rateLimit("archive-run", { max: 30, windowSec: 60 }); const runId = formData.get("runId"); if (typeof runId !== "string") return; const ownedId = await checkRunOwnership(runId); if (!ownedId) return; await db .update(reminderRuns) .set({ archivedAt: new Date() }) .where(eq(reminderRuns.id, ownedId)); revalidatePath("/"); revalidatePath("/activity"); } /** Move a previously-archived run back to the default activity list. */ export async function unarchiveRunAction(formData: FormData): Promise { await rateLimit("unarchive-run", { max: 30, windowSec: 60 }); const runId = formData.get("runId"); if (typeof runId !== "string") return; const ownedId = await checkRunOwnership(runId); if (!ownedId) return; await db .update(reminderRuns) .set({ archivedAt: null }) .where(eq(reminderRuns.id, ownedId)); revalidatePath("/"); revalidatePath("/activity"); } /** Hard-delete one run. Cascades through reminder_run_targets via FK. */ export async function deleteRunAction(formData: FormData): Promise { await rateLimit("delete-run", { max: 30, windowSec: 60 }); const runId = formData.get("runId"); if (typeof runId !== "string") return; const ownedId = await checkRunOwnership(runId); if (!ownedId) return; await db.delete(reminderRuns).where(eq(reminderRuns.id, ownedId)); revalidatePath("/"); revalidatePath("/activity"); }