Accounts list (mobile):
* Each row is a SwipeableRow. Swipe right reveals Pair (or Unpair if
the account is connected). Swipe left reveals Groups + Delete.
* The right shelf widens to 176px so two buttons fit comfortably; a
new \`leftShelfWidth\`/\`rightShelfWidth\` prop on SwipeableRow drives
the override (default 88 stays for single-button shelves).
Accounts list (desktop): unchanged grid of clickable cards.
Account detail:
* New "Name" card at the top opens /accounts/[id]/edit/label, the
dedicated rename surface (mirrors the reminder edit-name pattern).
* Paired-at row now shows the full timestamp ("10 May 2026, 3:33:04 pm")
via toLocaleString instead of toLocaleDateString.
Reminder wizard:
* Disconnected accounts on the "Account" step are no longer
clickable. They render as a non-link with aria-disabled, dimmed
to opacity-50 with cursor-not-allowed and a "Pair this account
before scheduling a reminder from it" tooltip. The bot has no
live session for those accounts, so this prevents broken submits.
renameAccountAction validates the label, rejects duplicates within
the same operator, and revalidates /accounts and the detail page.
Tests added:
* AccountSwipeableRow — 6 SSR tests (shelves rendered, conditional
Pair/Unpair, Groups + Delete shelf, 176px shelf width, hidden
accountId field).
* EditAccountLabelForm — 5 SSR + payload tests (prefill, Save
button, required + maxLength=60, action call shape).
* StepAccount — 4 SSR tests (connected → Link, disconnected → no
Link + aria-disabled, opacity/cursor styles, "Not connected"
copy).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
7.7 KiB
TypeScript
219 lines
7.7 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));
|
|
// Wipe synced groups too — they belong to a different WA login now.
|
|
await db.delete(whatsappGroups).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;
|
|
// Stop any live session / clean session files first.
|
|
await pgNotifyBot({ type: "account.unpair", 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`);
|
|
}
|