From fe135cdef5a840165a98ad2e04787a1994161175 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 09:34:46 +0800 Subject: [PATCH] fix: don't hide accounts in 'pending' state; park failed pairs as 'unpaired' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/bot/src/ipc/pair-handler.ts | 31 +++++++++++++++++++++++++------ apps/web/src/lib/queries.ts | 13 +++++++------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/apps/bot/src/ipc/pair-handler.ts b/apps/bot/src/ipc/pair-handler.ts index ad4d31f..966163b 100644 --- a/apps/bot/src/ipc/pair-handler.ts +++ b/apps/bot/src/ipc/pair-handler.ts @@ -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 { // 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 { } 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 { 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 { const cutoff = new Date(Date.now() - 60 * 60 * 1000); const stale = await db @@ -184,7 +200,10 @@ export async function sweepStalePendingAccounts(): Promise { .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"); } } diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 34ccba8..58dd900 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -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)], }); }