fix: don't hide accounts in 'pending' state; park failed pairs as 'unpaired'

Accounts list was hiding any row in the transient `pending` status
(originally meant only for an active QR scan). When a pair attempt
failed (timeout, transient connection error, page closed mid-scan)
the row was left in `pending` and silently disappeared from the
overview — the operator's "I created an account but it's gone" bug.

Two-part fix:

- listAccounts no longer filters by status. The status badge tells
  the operator what state each row is in; hiding rows just hides
  bugs.

- Pairing lifecycle no longer leaves rows in `pending` after failure.
  When the pair-handler sees a close (Baileys exhausting QR refs, or
  the pair-window timeout firing), it now sets `status='unpaired'`
  and clears `last_qr_png`. The row settles into a state that the
  detail page can act on (Re-pair / Delete) and remains visible on
  the list.

- The bot startup sweep used to DELETE stale pending rows older than
  1 hour. It now parks them as `unpaired` instead, keeping them
  visible so the operator notices and can retry.

Stuck `haha` row in the live DB also flipped to `unpaired` so it
reappears on the list immediately.

98 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 09:34:46 +08:00
parent 8ca7ebdd5b
commit fe135cdef5
2 changed files with 32 additions and 12 deletions

View File

@ -37,8 +37,14 @@ async function abandonPair(accountId: string): Promise<{ existed: boolean; label
if (sessionManager.hasSession(accountId)) {
await sessionManager.stop(accountId);
}
// Throw away the partial Baileys session files so the next pair
// attempt starts clean — but KEEP the account row so the operator
// sees it on the list with a "Re-pair" affordance.
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
await db
.update(whatsappAccounts)
.set({ status: "unpaired", lastQrPng: null })
.where(eq(whatsappAccounts.id, accountId));
return { existed: true, label: account.label };
}
@ -132,8 +138,9 @@ export async function handleStartPairing(accountId: string): Promise<void> {
// During the pairing window, ANY close means the QR window
// ended without a successful link — Baileys' default is to
// close after exhausting QR refs (~2.5 min). Surface this to
// the UI so the user gets a "pairing timed out" screen and a
// chance to retry, instead of staring at a stale QR forever.
// the UI so the user gets a "pairing timed out" screen, and
// park the row in a stable state so it shows up cleanly on
// the accounts list with a "Re-pair" affordance.
const t = pairTimeouts.get(id);
if (t) {
clearTimeout(t);
@ -141,6 +148,10 @@ export async function handleStartPairing(accountId: string): Promise<void> {
}
lastQrPayload.delete(id);
offByAccount.delete(id);
await db
.update(whatsappAccounts)
.set({ status: "unpaired", lastQrPng: null })
.where(eq(whatsappAccounts.id, id));
await pgNotifyWeb({ type: "session.timeout", accountId: id });
off();
}
@ -175,7 +186,12 @@ export async function handleStartPairing(accountId: string): Promise<void> {
pairTimeouts.set(accountId, timeoutId);
}
/** Sweep stale pending accounts on bot startup. */
/**
* Sweep stale `pending` accounts on bot startup. The bot was probably
* restarted mid-pair (or the operator never finished scanning) the
* row is parked as `unpaired` so the operator sees it on the list and
* can hit Re-pair, instead of silently disappearing.
*/
export async function sweepStalePendingAccounts(): Promise<void> {
const cutoff = new Date(Date.now() - 60 * 60 * 1000);
const stale = await db
@ -184,7 +200,10 @@ export async function sweepStalePendingAccounts(): Promise<void> {
.where(and(eq(whatsappAccounts.status, "pending"), lt(whatsappAccounts.createdAt, cutoff)));
for (const row of stale) {
await rm(join(env.SESSIONS_DIR, row.id), { recursive: true, force: true });
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, row.id));
logger.info({ accountId: row.id, label: row.label }, "sweep: removed stale pending account");
await db
.update(whatsappAccounts)
.set({ status: "unpaired", lastQrPng: null })
.where(eq(whatsappAccounts.id, row.id));
logger.info({ accountId: row.id, label: row.label }, "sweep: parked stale pending account as unpaired");
}
}

View File

@ -44,13 +44,14 @@ export async function getDashboardStats(operatorId: string) {
}
export async function listAccounts(operatorId: string) {
// Show all accounts except those in the transient `pending` state
// (active QR scan in progress — they're surfaced via the pairing page,
// and abandoned ones are swept by the bot). Operators see unpaired,
// connected, disconnected, and banned accounts so they can manage them.
// Show every account the operator owns, regardless of status. The
// status badge tells the user what state each row is in (Pending /
// Unpaired / Connected / Disconnected / etc), and the detail page
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
// by status produced phantom "I created an account but it's gone"
// bug reports.
return db.query.whatsappAccounts.findMany({
where: (a, { eq, and, ne }) =>
and(eq(a.operatorId, operatorId), ne(a.status, "pending")),
where: (a, { eq }) => eq(a.operatorId, operatorId),
orderBy: (a, { asc }) => [asc(a.label)],
});
}