3 Commits

Author SHA1 Message Date
731d6d66a6 fix(unpair): stop session manager from racing the web's status write
Symptom
-------
Click "Unpair" on a connected account. The web action sets
\`status='unpaired'\`, but the account detail page often still shows
"Disconnected" — and on accounts that had been previously connected,
the QR pair flow restarts a few seconds later all on its own.

Cause
-----
Two races inside the session manager:

1. The web's \`unpairAccountAction\` notifies the bot via \`pg_notify\`
   and then writes \`status='unpaired'\` to the row. The bot's
   \`handleUnpair\` calls \`sessionManager.stop()\` which closes the
   Baileys socket; Baileys eventually fires a \`connection: close\`
   event which the manager's \`handleEvent\` translates into a
   \`status='disconnected'\` UPDATE. Whichever write lands second wins.
   The user clicks Unpair and sees Disconnected.

2. The same close-event handler schedules a 5-second
   \`stop().then(start())\` reconnect for accounts whose
   \`lastConnectedAt\` is set. Five seconds after unpair, the bot
   silently re-opens the socket, the row flips to \`pending\`, and the
   QR carousel restarts.

Fix
---
\`stop(accountId, { intentional: true })\` marks the account in a new
\`intentionalStops\` Set. When the close event lands, \`handleEvent\`
drains the flag (with \`Set.delete()\` returning whether the key was
present, so it's exactly-once and a stale flag can't bleed into a
later session) and skips both the DB UPDATE and the reconnect
schedule. The caller — only \`handleUnpair\` for now — is the one
choosing the row's next state, so we step out of its way.

The flag is set ONLY when callers ask for it. Internal recoveries
(restartRequired auto re-open, ephemeral-close back-off) keep the
default behaviour and continue to write \`disconnected\` + reschedule.

Drive-bys
---------
- Refresh the stale "the row is gone by the time we run" comment in
  unpair-handler — the row stays alive now (the operator can re-pair
  without retyping the label). Look up the account first so the
  audit log carries the real \`operatorId\` instead of \`null\`. The
  delete-account flow really does delete the row before notifying us;
  the lookup tolerates that and falls back to \`null\`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:39:56 +08:00
9437df74ee feat(web): split Add Account from Pair; add Unpair/Re-pair/Delete actions
Reshape the account lifecycle to match how operators actually want to
work the system:

- Add Account → creates a row with status='unpaired'. No QR yet; the
  operator lands on the detail page.
- Pair / Re-pair → transitions an unpaired account to status='pending'
  and opens the live QR flow. Works for first-time pair AND for re-pair
  of an account that was previously unpaired.
- Unpair → asks the bot to stop the live Baileys session and clean
  session files; sets status='unpaired' but KEEPS the row (and its
  reminders) so the operator can re-pair without retyping anything.
- Delete → permanently removes the account and cascades to its groups,
  reminders, run history.

Schema:
- whatsapp_groups.account_id and reminders.account_id now have
  ON DELETE CASCADE so deleting an account fans out cleanly.

UI:
- /accounts list shows everything except the transient 'pending' state.
- /accounts/[id] shows state-aware buttons: Pair (when unpaired/banned/
  disconnected), Sync + Unpair (when connected), Delete (always).
- /accounts/new is now an "Add Account" form (label only).

Other fixes:
- next.config.ts: allowedDevOrigins includes 192.168.0.253 +
  test/rexwa subdomains so Server Actions work across the LAN.
- packages/shared/src/rrule.ts: rrule@2.8.1 has no exports field and
  ships ESM that some bundlers can't resolve via default OR named
  import. Use createRequire to bridge — works under both NodeNext
  (bot runtime) and Turbopack (web SSR).
2026-05-10 00:27:33 +08:00
af21bc5599 feat(bot): add IPC handlers for pair / unpair / sync / send-test / schedule 2026-05-09 22:34:01 +08:00