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)) { if (sessionManager.hasSession(accountId)) {
await sessionManager.stop(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 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 }; 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 // During the pairing window, ANY close means the QR window
// ended without a successful link — Baileys' default is to // ended without a successful link — Baileys' default is to
// close after exhausting QR refs (~2.5 min). Surface this 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 // the UI so the user gets a "pairing timed out" screen, and
// chance to retry, instead of staring at a stale QR forever. // 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); const t = pairTimeouts.get(id);
if (t) { if (t) {
clearTimeout(t); clearTimeout(t);
@ -141,6 +148,10 @@ export async function handleStartPairing(accountId: string): Promise<void> {
} }
lastQrPayload.delete(id); lastQrPayload.delete(id);
offByAccount.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 }); await pgNotifyWeb({ type: "session.timeout", accountId: id });
off(); off();
} }
@ -175,7 +186,12 @@ export async function handleStartPairing(accountId: string): Promise<void> {
pairTimeouts.set(accountId, timeoutId); 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> { export async function sweepStalePendingAccounts(): Promise<void> {
const cutoff = new Date(Date.now() - 60 * 60 * 1000); const cutoff = new Date(Date.now() - 60 * 60 * 1000);
const stale = await db const stale = await db
@ -184,7 +200,10 @@ export async function sweepStalePendingAccounts(): Promise<void> {
.where(and(eq(whatsappAccounts.status, "pending"), lt(whatsappAccounts.createdAt, cutoff))); .where(and(eq(whatsappAccounts.status, "pending"), lt(whatsappAccounts.createdAt, cutoff)));
for (const row of stale) { for (const row of stale) {
await rm(join(env.SESSIONS_DIR, row.id), { recursive: true, force: true }); await rm(join(env.SESSIONS_DIR, row.id), { recursive: true, force: true });
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, row.id)); await db
logger.info({ accountId: row.id, label: row.label }, "sweep: removed stale pending account"); .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) { export async function listAccounts(operatorId: string) {
// Show all accounts except those in the transient `pending` state // Show every account the operator owns, regardless of status. The
// (active QR scan in progress — they're surfaced via the pairing page, // status badge tells the user what state each row is in (Pending /
// and abandoned ones are swept by the bot). Operators see unpaired, // Unpaired / Connected / Disconnected / etc), and the detail page
// connected, disconnected, and banned accounts so they can manage them. // 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({ return db.query.whatsappAccounts.findMany({
where: (a, { eq, and, ne }) => where: (a, { eq }) => eq(a.operatorId, operatorId),
and(eq(a.operatorId, operatorId), ne(a.status, "pending")),
orderBy: (a, { asc }) => [asc(a.label)], orderBy: (a, { asc }) => [asc(a.label)],
}); });
} }