From 2fe8459d25f1ebed97a9828d529eb82fbfe404c0 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 21:26:58 +0800 Subject: [PATCH] feat: duplicate-pair detection + logout-before-delete + ordering tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three connected bits of paired-account hygiene: 1. Duplicate-pair guard (apps/bot/src/ipc/pair-handler.ts) Operator scans the QR with a phone that's already linked to another account row → both rows would fight over the same WhatsApp device and sends become a coin flip. After Baileys' `open` event the bot now queries siblings of the same operator, passes them through findDuplicateExistingAccount() (a pure helper extracted to pair-state.ts), and on a hit: - stops the new session (intentional; keeps the original's session intact) - scrubs the partial auth blob from disk - resets the row's status to unpaired and clears phone_number - emits a new session.duplicate event with the existing row's label so PairLive can render a clear message New PairLive 'duplicate' phase: amber icon + "Phone already linked, unpair the existing account first or scan with a different phone". 2. Logout-before-delete (apps/bot/src/ipc/unpair-handler.ts + apps/bot/src/whatsapp/session-manager.ts) Delete used to call account.unpair which only closes the local socket — the operator's phone kept showing a phantom "linked device" pointing at a row that no longer exists. Added: - new account.delete command type (web side and bot side) - sessionManager.logoutAndStop(): calls socket.logout() so WhatsApp drops the device on the server side, THEN closes the local socket. Best-effort; logout RPC failure doesn't strand the delete. - new handleDelete() handler that calls logoutAndStop, removes session files, audits, and notifies. - deleteAccountAction now sends account.delete instead of account.unpair. Unpair stays unchanged — re-pair-friendly, no logout. 3. Tests (bot 77 → 88, web 477 → 480) - findDuplicateExistingAccount: 6 cases covering match, no-match, self-exclusion, null/empty/whitespace handling, whitespace normalisation, deterministic-pick when (defensively) two siblings share a phone. - handleUnpair / handleDelete: handleDelete calls logoutAndStop BEFORE rm; handleUnpair never touches logoutAndStop (regression guard for a refactor that swaps them); audit log payload includes the row's label; audit lookup throwing doesn't strand the delete. - listAccounts ordering: static guard against the rename- reshuffles-list regression. Pins `asc(a.createdAt)` + `asc(a.id)` and rejects `asc(a.label)` in the function body. Bot restarted with the new flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/bot/src/ipc/command-consumer.ts | 10 +- apps/bot/src/ipc/notify.ts | 10 ++ apps/bot/src/ipc/pair-handler.ts | 53 +++++++- apps/bot/src/ipc/pair-state.test.ts | 103 +++++++++++++++ apps/bot/src/ipc/pair-state.ts | 42 ++++++ apps/bot/src/ipc/unpair-handler.test.ts | 128 +++++++++++++++++++ apps/bot/src/ipc/unpair-handler.ts | 38 ++++++ apps/bot/src/whatsapp/session-manager.ts | 38 ++++++ apps/web/src/actions/accounts.ts | 8 +- apps/web/src/components/pair-live.tsx | 41 +++++- apps/web/src/hooks/use-events.ts | 5 + apps/web/src/lib/list-accounts-order.test.ts | 49 +++++++ apps/web/src/lib/notify.ts | 4 + 13 files changed, 524 insertions(+), 5 deletions(-) create mode 100644 apps/bot/src/ipc/unpair-handler.test.ts create mode 100644 apps/web/src/lib/list-accounts-order.test.ts diff --git a/apps/bot/src/ipc/command-consumer.ts b/apps/bot/src/ipc/command-consumer.ts index bfd29e4..085586f 100644 --- a/apps/bot/src/ipc/command-consumer.ts +++ b/apps/bot/src/ipc/command-consumer.ts @@ -3,7 +3,7 @@ import type { Notification } from "pg"; import { logger } from "../logger.js"; import { env } from "../env.js"; import { handleStartPairing } from "./pair-handler.js"; -import { handleUnpair } from "./unpair-handler.js"; +import { handleUnpair, handleDelete } from "./unpair-handler.js"; import { handleSyncGroups } from "./sync-groups-handler.js"; import { handleSendTest } from "./send-test-handler.js"; import { @@ -14,6 +14,11 @@ import { export type BotCommand = | { type: "account.start_pairing"; accountId: string } | { type: "account.unpair"; accountId: string } + // Like unpair, but tells WhatsApp to drop this device from the + // user's linked-devices list first via socket.logout(). The web + // action calls this immediately before deleting the row so the + // operator's phone doesn't keep showing a phantom linked device. + | { type: "account.delete"; accountId: string } | { type: "account.sync_groups"; accountId: string } | { type: "group.send_test"; groupId: string; text: string } | { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string } @@ -74,6 +79,9 @@ export function registerDefaultHandlers(): void { registerHandler("account.unpair", async (cmd) => { await handleUnpair(cmd.accountId); }); + registerHandler("account.delete", async (cmd) => { + await handleDelete(cmd.accountId); + }); registerHandler("account.sync_groups", async (cmd) => { await handleSyncGroups(cmd.accountId); }); diff --git a/apps/bot/src/ipc/notify.ts b/apps/bot/src/ipc/notify.ts index 4f9265f..4af796e 100644 --- a/apps/bot/src/ipc/notify.ts +++ b/apps/bot/src/ipc/notify.ts @@ -10,6 +10,16 @@ export type WebEvent = | { type: "session.connected"; accountId: string; phoneNumber: string | null } | { type: "session.disconnected"; accountId: string } | { type: "session.timeout"; accountId: string } + // Operator scanned the QR with a phone that's already linked to another + // account row. We park the new pairing instead of letting two account + // rows fight over the same WhatsApp device. existingLabel surfaces in + // the UI so the operator knows which account already owns the phone. + | { + type: "session.duplicate"; + accountId: string; + phoneNumber: string; + existingLabel: string; + } | { type: "groups.synced"; accountId: string; count: number } | { type: "reminder.fired"; diff --git a/apps/bot/src/ipc/pair-handler.ts b/apps/bot/src/ipc/pair-handler.ts index ecc1751..a7f82d3 100644 --- a/apps/bot/src/ipc/pair-handler.ts +++ b/apps/bot/src/ipc/pair-handler.ts @@ -10,7 +10,11 @@ import { renderQrPng } from "../whatsapp/qr-renderer.js"; import { syncGroupsForAccount } from "../whatsapp/group-sync.js"; import { writeAuditLog } from "../audit.js"; import { pgNotifyWeb } from "./notify.js"; -import { decidePairListenerOnClose, nextWarmingUpAfterEvent } from "./pair-state.js"; +import { + decidePairListenerOnClose, + findDuplicateExistingAccount, + nextWarmingUpAfterEvent, +} from "./pair-state.js"; const PAIR_TIMEOUT_MS = 5 * 60 * 1000; const offByAccount = new Map void>(); @@ -126,6 +130,53 @@ export async function handleStartPairing(accountId: string): Promise { } lastQrPayload.delete(id); offByAccount.delete(id); + + // Duplicate-pair guard. Operator scanned the QR with a phone + // that's already linked to another account row. Letting both + // rows claim the same WhatsApp device confuses Baileys and + // turns sends into a coin flip — abandon this pairing and + // surface a clear message to the UI. + const siblings = await db.query.whatsappAccounts.findMany({ + where: (a, { eq: dEq }) => dEq(a.operatorId, account.operatorId), + columns: { id: true, phoneNumber: true, label: true }, + }); + const dup = findDuplicateExistingAccount({ + currentAccountId: id, + currentPhoneNumber: event.phoneNumber, + siblings, + }); + if (dup) { + logger.warn( + { + accountId: id, + phoneNumber: event.phoneNumber, + existingAccountId: dup.existingAccountId, + existingLabel: dup.existingLabel, + }, + "pair: duplicate phone — abandoning new pairing", + ); + // Stop the duplicate session, scrub the partial auth blob, + // and reset the row's status. We DO NOT logout() here — the + // original account's session remains valid and the operator + // hasn't actually added a new linked device on the phone yet + // (it'd just be the freshly-completed scan, which Baileys + // hasn't yet committed to the WhatsApp side). + await sessionManager.stop(id, { intentional: true }); + await rm(join(env.SESSIONS_DIR, id), { recursive: true, force: true }); + await db + .update(whatsappAccounts) + .set({ status: "unpaired", lastQrPng: null, phoneNumber: null }) + .where(eq(whatsappAccounts.id, id)); + await pgNotifyWeb({ + type: "session.duplicate", + accountId: id, + phoneNumber: event.phoneNumber!, + existingLabel: dup.existingLabel, + }); + off(); + return; + } + const session = sessionManager.getSession(id); let synced = 0; if (session) { diff --git a/apps/bot/src/ipc/pair-state.test.ts b/apps/bot/src/ipc/pair-state.test.ts index ead1371..cd51b6b 100644 --- a/apps/bot/src/ipc/pair-state.test.ts +++ b/apps/bot/src/ipc/pair-state.test.ts @@ -3,6 +3,7 @@ import { decideOnPairClose, decideOnPairTimeout, decidePairListenerOnClose, + findDuplicateExistingAccount, nextWarmingUpAfterEvent, shouldAutoReconnect, } from "./pair-state.js"; @@ -204,3 +205,105 @@ describe("nextWarmingUpAfterEvent (pair-listener flag transitions)", () => { expect(warming).toBe(false); }); }); + +describe("findDuplicateExistingAccount (one-phone-per-account guard)", () => { + const sibling = (id: string, phone: string | null, label: string) => ({ + id, + phoneNumber: phone, + label, + }); + + it("flags a sibling that already holds this phone number", () => { + const r = findDuplicateExistingAccount({ + currentAccountId: "new", + currentPhoneNumber: "60123456789", + siblings: [ + sibling("new", null, "scratch"), + sibling("existing", "60123456789", "Yiekheng-my"), + sibling("other", "60987654321", "WaBot Test"), + ], + }); + expect(r).toEqual({ + existingAccountId: "existing", + existingLabel: "Yiekheng-my", + }); + }); + + it("returns null when the phone is unique", () => { + const r = findDuplicateExistingAccount({ + currentAccountId: "new", + currentPhoneNumber: "60123456789", + siblings: [ + sibling("new", null, "scratch"), + sibling("other", "60987654321", "WaBot"), + ], + }); + expect(r).toBeNull(); + }); + + it("excludes the current account from comparison (a row's own phone isn't a 'duplicate')", () => { + // After session-manager.handleEvent runs first it has already + // written phone_number on the current row. The check must skip + // that row, otherwise EVERY successful pair would match itself + // and look like a duplicate. + const r = findDuplicateExistingAccount({ + currentAccountId: "self", + currentPhoneNumber: "60123456789", + siblings: [sibling("self", "60123456789", "Self")], + }); + expect(r).toBeNull(); + }); + + it("returns null for null/empty/whitespace phone numbers (don't false-positive on unset)", () => { + const siblings = [ + sibling("new", null, "scratch"), + sibling("a", null, "Old A"), + sibling("b", "", "Old B"), + sibling("c", " ", "Old C"), + ]; + expect( + findDuplicateExistingAccount({ + currentAccountId: "new", + currentPhoneNumber: null, + siblings, + }), + ).toBeNull(); + expect( + findDuplicateExistingAccount({ + currentAccountId: "new", + currentPhoneNumber: "", + siblings, + }), + ).toBeNull(); + expect( + findDuplicateExistingAccount({ + currentAccountId: "new", + currentPhoneNumber: " ", + siblings, + }), + ).toBeNull(); + }); + + it("normalises whitespace on both sides before comparing", () => { + const r = findDuplicateExistingAccount({ + currentAccountId: "new", + currentPhoneNumber: " 60123456789 ", + siblings: [sibling("existing", "60123456789", "Existing")], + }); + expect(r?.existingAccountId).toBe("existing"); + }); + + it("picks the FIRST matching sibling when (somehow) two rows share the phone", () => { + // Defensive: this state shouldn't exist in production but the helper + // should at least be deterministic so the message is consistent. + const r = findDuplicateExistingAccount({ + currentAccountId: "new", + currentPhoneNumber: "60123456789", + siblings: [ + sibling("first", "60123456789", "First"), + sibling("second", "60123456789", "Second"), + ], + }); + expect(r?.existingAccountId).toBe("first"); + }); +}); diff --git a/apps/bot/src/ipc/pair-state.ts b/apps/bot/src/ipc/pair-state.ts index 43a1e2d..7a3af33 100644 --- a/apps/bot/src/ipc/pair-state.ts +++ b/apps/bot/src/ipc/pair-state.ts @@ -141,3 +141,45 @@ export function nextWarmingUpAfterEvent(input: { if (input.event === "close" && input.restartRequired) return true; return input.warmingUp; } + +/** + * Decide whether a freshly-paired account is a duplicate of an + * existing account row owned by the same operator. The operator + * cannot legitimately link the same WhatsApp number to two account + * rows — Baileys keeps one auth blob per phone and the second row + * would just hijack the first's session. + * + * Inputs: + * - `currentAccountId` the row that just received the open event + * - `currentPhoneNumber` the JID-derived phone string (or null) + * - `siblings` every other operator-owned account row + * + * Returns `null` if the phone is unique (proceed normally), or a + * descriptor with the existing-row's id+label so the caller can park + * the duplicate row and surface a clear "already linked" message to + * the UI. A null/empty phone never reports a duplicate (we'd be + * comparing apples and we'd block legitimate first pairs that + * haven't received the WID yet). + */ +export interface DuplicatePairInput { + currentAccountId: string; + currentPhoneNumber: string | null | undefined; + siblings: Array<{ id: string; phoneNumber: string | null; label: string }>; +} +export interface DuplicatePairFinding { + existingAccountId: string; + existingLabel: string; +} +export function findDuplicateExistingAccount( + input: DuplicatePairInput, +): DuplicatePairFinding | null { + const phone = (input.currentPhoneNumber ?? "").trim(); + if (!phone) return null; + for (const s of input.siblings) { + if (s.id === input.currentAccountId) continue; + if ((s.phoneNumber ?? "").trim() === phone) { + return { existingAccountId: s.id, existingLabel: s.label }; + } + } + return null; +} diff --git a/apps/bot/src/ipc/unpair-handler.test.ts b/apps/bot/src/ipc/unpair-handler.test.ts new file mode 100644 index 0000000..fb28d06 --- /dev/null +++ b/apps/bot/src/ipc/unpair-handler.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Hoisted spies so the vi.mock factories can reach them. +const { + stopMock, + logoutAndStopMock, + rmMock, + findFirstMock, + writeAuditLogMock, + pgNotifyWebMock, +} = vi.hoisted(() => ({ + stopMock: vi.fn(async () => undefined), + logoutAndStopMock: vi.fn(async () => undefined), + rmMock: vi.fn(async () => undefined), + findFirstMock: vi.fn(async () => ({ operatorId: "op-1", label: "WaBot" })), + writeAuditLogMock: vi.fn(async () => undefined), + pgNotifyWebMock: vi.fn(async () => undefined), +})); + +vi.mock("node:fs/promises", () => ({ + rm: (...args: unknown[]) => rmMock(...args), +})); +vi.mock("../db.js", () => ({ + db: { + query: { whatsappAccounts: { findFirst: (...a: unknown[]) => findFirstMock(...a) } }, + }, +})); +vi.mock("../env.js", () => ({ env: { SESSIONS_DIR: "/data/sessions" } })); +vi.mock("../whatsapp/session-manager.js", () => ({ + sessionManager: { + stop: (...a: unknown[]) => stopMock(...a), + logoutAndStop: (...a: unknown[]) => logoutAndStopMock(...a), + }, +})); +vi.mock("../audit.js", () => ({ + writeAuditLog: (...a: unknown[]) => writeAuditLogMock(...a), +})); +vi.mock("./notify.js", () => ({ + pgNotifyWeb: (...a: unknown[]) => pgNotifyWebMock(...a), +})); +vi.mock("../logger.js", () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); + +import { handleUnpair, handleDelete } from "./unpair-handler.js"; + +beforeEach(() => { + stopMock.mockReset(); + stopMock.mockResolvedValue(undefined); + logoutAndStopMock.mockReset(); + logoutAndStopMock.mockResolvedValue(undefined); + rmMock.mockReset(); + rmMock.mockResolvedValue(undefined); + findFirstMock.mockReset(); + findFirstMock.mockResolvedValue({ operatorId: "op-1", label: "WaBot" }); + writeAuditLogMock.mockReset(); + writeAuditLogMock.mockResolvedValue(undefined); + pgNotifyWebMock.mockReset(); + pgNotifyWebMock.mockResolvedValue(undefined); +}); + +describe("handleUnpair", () => { + it("stops the session WITHOUT logout, removes files, audits, notifies", async () => { + await handleUnpair("acct-A"); + // The unpair flow MUST NOT call logoutAndStop — that would tell + // WhatsApp to drop the linked device, which the operator might + // re-pair shortly after. logoutAndStop is only for permanent + // delete. + expect(logoutAndStopMock).not.toHaveBeenCalled(); + expect(stopMock).toHaveBeenCalledWith("acct-A", { intentional: true }); + expect(rmMock).toHaveBeenCalled(); + expect(writeAuditLogMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: "account.unpaired", targetId: "acct-A" }), + ); + expect(pgNotifyWebMock).toHaveBeenCalledWith({ + type: "session.disconnected", + accountId: "acct-A", + }); + }); +}); + +describe("handleDelete (logout-before-teardown)", () => { + it("calls logoutAndStop BEFORE rm so WhatsApp drops the linked device first", async () => { + await handleDelete("acct-A"); + expect(logoutAndStopMock).toHaveBeenCalledTimes(1); + expect(logoutAndStopMock).toHaveBeenCalledWith("acct-A"); + expect(rmMock).toHaveBeenCalledTimes(1); + // Order: logout-and-stop must invoke before rm (otherwise the + // socket was torn down on disk before WhatsApp could be told to + // drop the linked device). + expect(logoutAndStopMock.mock.invocationCallOrder[0]).toBeLessThan( + rmMock.mock.invocationCallOrder[0]!, + ); + }); + + it("does NOT call the plain stop() — that's reserved for unpair", async () => { + // Sanity guard: a refactor that swaps logoutAndStop for stop() + // would silently regress the linked-device cleanup. The test + // pins the contract. + await handleDelete("acct-A"); + expect(stopMock).not.toHaveBeenCalled(); + }); + + it("writes an account.deleted audit log carrying the row's label", async () => { + findFirstMock.mockResolvedValueOnce({ operatorId: "op-7", label: "Yiekheng-my" }); + await handleDelete("acct-X"); + expect(writeAuditLogMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "account.deleted", + operatorId: "op-7", + targetId: "acct-X", + payload: { label: "Yiekheng-my" }, + }), + ); + }); + + it("still completes when the audit-log lookup fails (best-effort)", async () => { + // The web action runs the cascade DELETE right after; if the row + // is gone before this handler reads it, the audit lookup throws. + // Delete must not strand on that. + findFirstMock.mockRejectedValueOnce(new Error("no such row")); + await expect(handleDelete("acct-A")).resolves.toBeUndefined(); + expect(rmMock).toHaveBeenCalled(); + expect(pgNotifyWebMock).toHaveBeenCalled(); + }); +}); diff --git a/apps/bot/src/ipc/unpair-handler.ts b/apps/bot/src/ipc/unpair-handler.ts index 07e1abe..47deca9 100644 --- a/apps/bot/src/ipc/unpair-handler.ts +++ b/apps/bot/src/ipc/unpair-handler.ts @@ -39,3 +39,41 @@ export async function handleUnpair(accountId: string): Promise { } await pgNotifyWeb({ type: "session.disconnected", accountId }); } + +/** + * Delete-account flow on the bot side. Distinct from unpair because + * we want WhatsApp to drop this device from the user's linked-devices + * list — otherwise the phone keeps showing a phantom entry that has + * to be manually removed from WhatsApp's UI. + * + * Order is important: + * 1. socket.logout() over the still-connected socket → WhatsApp + * removes the linked device on the server side. + * 2. close() the local Baileys session. + * 3. rm() the on-disk auth blob so the next pairing starts clean. + * + * Step 1 is best-effort — if the socket is already torn down or the + * RPC fails the delete still proceeds. The web action then deletes + * the row (cascade FKs handle groups/reminders/runs). + */ +export async function handleDelete(accountId: string): Promise { + await sessionManager.logoutAndStop(accountId); + await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true }); + try { + const row = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq }) => eq(a.id, accountId), + columns: { operatorId: true, label: true }, + }); + await writeAuditLog(db, { + operatorId: row?.operatorId ?? null, + source: "web", + action: "account.deleted", + targetType: "whatsapp_account", + targetId: accountId, + payload: { label: row?.label ?? null }, + }); + } catch (err) { + logger.warn({ err, accountId }, "delete: audit log failed (non-fatal)"); + } + await pgNotifyWeb({ type: "session.disconnected", accountId }); +} diff --git a/apps/bot/src/whatsapp/session-manager.ts b/apps/bot/src/whatsapp/session-manager.ts index 7853f53..5fbe6da 100644 --- a/apps/bot/src/whatsapp/session-manager.ts +++ b/apps/bot/src/whatsapp/session-manager.ts @@ -120,6 +120,44 @@ class SessionManager { this.sessions.delete(accountId); } + /** + * Tell WhatsApp to remove this device from the linked-devices list, + * then close the socket. Used by the delete-account flow so the + * operator's phone doesn't keep showing a phantom "linked device" + * pointing at a row that no longer exists. Best-effort: if the + * socket is already torn down or the logout RPC fails (network + * blip, already-disconnected, etc.) we still proceed to close + + * teardown — no point stranding the delete because WhatsApp didn't + * acknowledge. + */ + async logoutAndStop(accountId: string): Promise { + const timer = this.reconnectTimers.get(accountId); + if (timer) { + clearTimeout(timer); + this.reconnectTimers.delete(accountId); + } + const session = this.sessions.get(accountId); + if (!session) return; + // Suppress reconnect/handleEvent bookkeeping for the close that + // logout() emits — the row is about to be deleted entirely so + // status writes are pointless. + this.intentionalStops.add(accountId); + try { + await session.socket.logout(); + } catch (err) { + logger.warn( + { err, accountId }, + "session-manager: socket.logout() failed (continuing with teardown)", + ); + } + try { + await session.close(); + } catch (err) { + logger.warn({ err, accountId }, "session-manager: post-logout close failed"); + } + this.sessions.delete(accountId); + } + async stopAll(): Promise { await Promise.all([...this.sessions.keys()].map((id) => this.stop(id))); } diff --git a/apps/web/src/actions/accounts.ts b/apps/web/src/actions/accounts.ts index 67c9643..c159857 100644 --- a/apps/web/src/actions/accounts.ts +++ b/apps/web/src/actions/accounts.ts @@ -193,8 +193,12 @@ export async function deleteAccountAction(formData: FormData): Promise { 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 }); + // 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"); diff --git a/apps/web/src/components/pair-live.tsx b/apps/web/src/components/pair-live.tsx index 49b372a..f685aae 100644 --- a/apps/web/src/components/pair-live.tsx +++ b/apps/web/src/components/pair-live.tsx @@ -18,7 +18,8 @@ type PairingState = | { phase: "waiting" } | { phase: "qr"; qrUrl: string } | { phase: "connected"; phoneNumber: string } - | { phase: "timeout" }; + | { phase: "timeout" } + | { phase: "duplicate"; phoneNumber: string; existingLabel: string }; interface PairLiveProps { accountId: string; @@ -112,6 +113,15 @@ export function PairLive({ accountId, label }: PairLiveProps) { if (timerRef.current) clearInterval(timerRef.current); setPairingState({ phase: "timeout" }); }, + "session.duplicate": (data) => { + if (data.accountId !== accountId) return; + if (timerRef.current) clearInterval(timerRef.current); + setPairingState({ + phase: "duplicate", + phoneNumber: data.phoneNumber, + existingLabel: data.existingLabel, + }); + }, }); // Auto-redirect on connected @@ -234,6 +244,35 @@ export function PairLive({ accountId, label }: PairLiveProps) { )} + + {pairingState.phase === "duplicate" && ( +
+
+ +
+
+

Phone already linked

+

+ + +{pairingState.phoneNumber.replace(/^\+/, "")} + {" "} + is already paired to{" "} + + {pairingState.existingLabel} + + . Each WhatsApp number can only be linked to one account here. + Unpair the existing account first, or scan with a different + phone. +

+
+ +
+ )} ); } diff --git a/apps/web/src/hooks/use-events.ts b/apps/web/src/hooks/use-events.ts index 95c3b6b..abf68c5 100644 --- a/apps/web/src/hooks/use-events.ts +++ b/apps/web/src/hooks/use-events.ts @@ -9,6 +9,11 @@ export type WebEventMap = { "session.connected": { accountId: string; phoneNumber: string | null }; "session.disconnected": { accountId: string }; "session.timeout": { accountId: string }; + "session.duplicate": { + accountId: string; + phoneNumber: string; + existingLabel: string; + }; "groups.synced": { accountId: string; count: number }; "reminder.fired": { reminderId: string; diff --git a/apps/web/src/lib/list-accounts-order.test.ts b/apps/web/src/lib/list-accounts-order.test.ts new file mode 100644 index 0000000..383383a --- /dev/null +++ b/apps/web/src/lib/list-accounts-order.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Static guard: listAccounts() in apps/web/src/lib/queries.ts MUST + * order rows by createdAt ascending (with id as a deterministic + * tiebreaker) so the operator's earliest-added account stays on top. + * + * Earlier the orderBy used `asc(a.label)` which silently re-shuffled + * the list every time an account was renamed. This test pins the + * fix in source so a future refactor can't quietly bring the rename + * regression back. + * + * It's a static (regex) guard rather than an integration test + * because the live query needs Postgres + a seeded operator; + * pinning the source spelling keeps coverage cheap and CI-friendly. + */ +describe("listAccounts ordering (regression guard)", () => { + const src = readFileSync( + join(__dirname, "queries.ts"), + "utf8", + ); + + it("orders by created_at ASC", () => { + // Match across whitespace/comments inside listAccounts. Anchors: + // function header → orderBy → asc(a.createdAt). + const fnStart = src.indexOf("export async function listAccounts("); + expect(fnStart).toBeGreaterThan(-1); + const fnEnd = src.indexOf("export async function ", fnStart + 1); + const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined); + expect(fnBody).toMatch(/orderBy:[\s\S]*asc\(a\.createdAt\)/); + }); + + it("uses id as a deterministic tiebreaker for ties on createdAt", () => { + const fnStart = src.indexOf("export async function listAccounts("); + const fnEnd = src.indexOf("export async function ", fnStart + 1); + const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined); + expect(fnBody).toMatch(/asc\(a\.id\)/); + }); + + it("does NOT order by label (the regression we're guarding against)", () => { + const fnStart = src.indexOf("export async function listAccounts("); + const fnEnd = src.indexOf("export async function ", fnStart + 1); + const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined); + expect(fnBody).not.toMatch(/asc\(a\.label\)/); + expect(fnBody).not.toMatch(/desc\(a\.label\)/); + }); +}); diff --git a/apps/web/src/lib/notify.ts b/apps/web/src/lib/notify.ts index 88de38c..f334d94 100644 --- a/apps/web/src/lib/notify.ts +++ b/apps/web/src/lib/notify.ts @@ -5,6 +5,10 @@ import { db } from "./db"; export type BotCommand = | { type: "account.start_pairing"; accountId: string } | { type: "account.unpair"; accountId: string } + // Like account.unpair, but the bot also calls socket.logout() so + // WhatsApp drops this device from the operator's linked-devices + // list before the row is deleted. + | { type: "account.delete"; accountId: string } | { type: "account.sync_groups"; accountId: string } | { type: "group.send_test"; groupId: string; text: string } | { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }