Web error log showed unpairAccountAction failing with the same FK violation as group-sync: deleting whatsapp_groups rows that had been used in reminders blew up reminder_targets_group_id_whatsapp_groups_id_fk and aborted the unpair. Switch to UPDATE … SET is_archived=true. The bot's group-sync upsert already flips is_archived back to false on a re-pair (added in the group-sync companion fix in the previous commit), so behaviour is end-to-end equivalent to the old delete + repopulate path without the FK fragility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
231 lines
8.3 KiB
TypeScript
231 lines
8.3 KiB
TypeScript
"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<AddAccountResult> {
|
|
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<RenameAccountResult> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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`);
|
|
}
|