yiekheng 2fe8459d25 feat: duplicate-pair detection + logout-before-delete + ordering tests
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>
2026-05-10 21:26:58 +08:00
..
2026-05-09 16:12:53 +08:00
2026-05-09 16:12:53 +08:00
2026-05-10 14:35:26 +08:00
2026-05-10 14:35:26 +08:00