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) <noreply@anthropic.com>