"use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { headers } from "next/headers"; import { z } from "zod"; import { eq } from "drizzle-orm"; import { whatsappAccounts, whatsappGroups } from "@cmbot/db"; import { db } from "@/lib/db"; import { getSeededOperator } from "@/lib/operator"; import { pgNotifyBot } from "@/lib/notify"; import { checkRateLimit } from "@/lib/rate-limit"; 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"); } const addAccountSchema = z.object({ label: z .string() .trim() .min(1, "Label is required") .max(60, "Label too long (max 60)"), }); export type AddAccountResult = | { ok: true; accountId: string } | { ok: false; error: string }; /** * Step 1 of the lifecycle: create an account row with status='unpaired'. * No QR scan yet. Caller redirects to /accounts/[id] where the operator * sees the account detail with a "Pair Now" button. */ export async function addAccountAction( _prev: unknown, formData: FormData, ): Promise { await rateLimit("add-account"); const parsed = addAccountSchema.safeParse({ label: formData.get("label") }); if (!parsed.success) { return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid label" }; } const op = await getSeededOperator(); const existing = await db.query.whatsappAccounts.findFirst({ where: (a, { eq, and }) => and(eq(a.operatorId, op.id), eq(a.label, parsed.data.label)), }); if (existing) { return { ok: false, error: `An account labelled "${parsed.data.label}" already exists.`, }; } const [created] = await db .insert(whatsappAccounts) .values({ operatorId: op.id, label: parsed.data.label, status: "unpaired" }) .returning({ id: whatsappAccounts.id }); revalidatePath("/accounts"); // eslint-disable-next-line @typescript-eslint/no-explicit-any redirect(`/accounts/${created!.id}` as any); } const renameAccountSchema = z.object({ accountId: z.string().uuid(), label: z .string() .trim() .min(1, "Label is required") .max(60, "Label too long (max 60)"), }); export type RenameAccountResult = | { ok: true } | { ok: false; error: string }; /** * Edit the operator-facing label for an existing account. The label is * what shows up in lists, the page header, and run history; it has no * effect on the WhatsApp side. */ export async function renameAccountAction(input: { accountId: string; label: string; }): Promise { await rateLimit("rename-account"); const parsed = renameAccountSchema.safeParse(input); if (!parsed.success) { return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; } const op = await getSeededOperator(); const account = await db.query.whatsappAccounts.findFirst({ where: (a, { eq, and }) => and(eq(a.id, parsed.data.accountId), eq(a.operatorId, op.id)), }); if (!account) return { ok: false, error: "Account not found" }; // Reject duplicate labels for the same operator. const dupe = await db.query.whatsappAccounts.findFirst({ where: (a, { eq, and, ne }) => and( eq(a.operatorId, op.id), eq(a.label, parsed.data.label), ne(a.id, parsed.data.accountId), ), }); if (dupe) { return { ok: false, error: `An account labelled "${parsed.data.label}" already exists.`, }; } await db .update(whatsappAccounts) .set({ label: parsed.data.label }) .where(eq(whatsappAccounts.id, parsed.data.accountId)); revalidatePath("/accounts"); revalidatePath(`/accounts/${parsed.data.accountId}`); return { ok: true }; } /** * Trigger pair / re-pair for an existing account. Transitions the row to * status='pending' and asks the bot to open a Baileys session. Operator * lands on the live QR page. */ export async function pairAccountAction(formData: FormData): Promise { await rateLimit("pair"); const accountId = formData.get("accountId"); if (typeof accountId !== "string") return; 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; if (account.status === "connected") { // Already connected — bounce to detail // eslint-disable-next-line @typescript-eslint/no-explicit-any redirect(`/accounts/${accountId}` as any); } await db .update(whatsappAccounts) .set({ status: "pending", lastQrAt: new Date() }) .where(eq(whatsappAccounts.id, accountId)); await pgNotifyBot({ type: "account.start_pairing", accountId }); revalidatePath("/accounts"); revalidatePath(`/accounts/${accountId}`); // eslint-disable-next-line @typescript-eslint/no-explicit-any redirect(`/accounts/${accountId}/pairing` as any); } /** * Unpair: stop the Baileys session and clear session files via the bot, * but KEEP the account row (status -> 'unpaired') so the operator can * re-pair without retyping the label or losing any references. */ export async function unpairAccountAction(formData: FormData): Promise { await rateLimit("unpair"); const accountId = formData.get("accountId"); if (typeof accountId !== "string") return; 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; await pgNotifyBot({ type: "account.unpair", accountId }); await db .update(whatsappAccounts) .set({ status: "unpaired", phoneNumber: null }) .where(eq(whatsappAccounts.id, accountId)); // Soft-archive synced groups instead of DELETEing. Hard delete // failed with "violates foreign key constraint // reminder_targets_group_id_whatsapp_groups_id_fk" whenever any // group had ever been used in a reminder, which aborted the // unpair. Archived groups vanish from the picker; a re-pair flips // them back via the on-conflict upsert in syncGroupsForAccount. await db .update(whatsappGroups) .set({ isArchived: true }) .where(eq(whatsappGroups.accountId, accountId)); revalidatePath("/accounts"); revalidatePath(`/accounts/${accountId}`); // eslint-disable-next-line @typescript-eslint/no-explicit-any redirect(`/accounts/${accountId}` as any); } /** * Permanently delete an account, its groups, reminders and run history * via the cascade FKs added in migration 0003. */ export async function deleteAccountAction(formData: FormData): Promise { await rateLimit("delete-account"); const accountId = formData.get("accountId"); if (typeof accountId !== "string") return; 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; // Tell the bot to logout() over the live socket FIRST (so WhatsApp // drops this device from the operator's linked-devices list), then // close + remove session files. Distinct from account.unpair which // never calls logout — keeping linked-devices clean is specific to // the delete flow. await pgNotifyBot({ type: "account.delete", accountId }); // Cascade FKs handle groups, reminders, runs, run_targets, messages. await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId)); revalidatePath("/accounts"); // eslint-disable-next-line @typescript-eslint/no-explicit-any redirect("/accounts" as any); } export async function syncGroupsAction(formData: FormData): Promise { await rateLimit("sync"); const accountId = formData.get("accountId"); if (typeof accountId !== "string") return; 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; await pgNotifyBot({ type: "account.sync_groups", accountId }); revalidatePath(`/accounts/${accountId}`); revalidatePath(`/accounts/${accountId}/groups`); }