yiekheng d731390c9d fix(web): unpair soft-archives groups instead of DELETE — same FK abort
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>
2026-05-10 21:33:03 +08:00

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`);
}