From 1a725c121a873a33f10113d53965d40aa4495c19 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:14:06 +0200 Subject: [PATCH] fix(whatsapp): four #1991/#1992 hardening residuals in the receipt channel (#2349) * fix(whatsapp): four #1991/#1992 hardening residuals in the receipt channel Sends now say HOW they failed (failure: http_rejected | transport_error), and the company question falls back to numbered text only on an HTTP rejection. A timeout means Meta may already have delivered the interactive question, so that case rolls the question back instead of putting a second copy on the phone; the next receipt re-asks. Both drains (the company answer and the single-live-company path) share one helper that stamps rows older than Meta's ~30-day media retention as company_choice_expired instead of re-opening them into the MAX_ATTEMPTS error path, and tell the sender once (M20) how many receipts could not be recovered. STAGED_MEDIA_MAX_AGE_MS moved to conversation.ts so the sweep and the drains read one definition. POST /link/default-company checks LIVE membership with the same companies!inner(archived_at) filter intake uses, so a default pointing at an archived company is refused (403) instead of saved and then silently ignored; a failed membership read is a 500, not a 403. consumeLinkCode is tri-state: a failed lookup or claim returns 'transient_error' and the webhook answers with a neutral retry (M21) instead of M2 "the code is wrong" to a user holding a valid code. In degraded mode (quota RPC down too) M21 sits behind the same fail-closed throttle as M2. Refs #2062 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv * fix(whatsapp): drain failures are reported and swept, M21 shares the M2 throttle Review pass on #2349 (CodeRabbit, four findings, one commit): - drainParkedRows reads the error of both UPDATEs, logs each, and returns failed: true. The answer stays applied (pin set, options claimed) and the single-company path still clears the dead question: both turn the rows that are still parked into orphans, and a new sweep pass re-opens parked rows whose conversation has no open company question (older than two minutes, so a row parked just before its question is armed is left alone) through the same drain and processes them. - badCodeThrottled counts M21 alongside M2, so a code-shaped flood during a lookup outage cannot earn one M21 per message in degraded mode. - The M20 expiry notice logs a failed send. It stays best-effort like M17, M18 and M19: the extension has no durable outbound retry, and the rows the notice describes are already terminal. Refs #2062 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv * fix(whatsapp): type the orphan scan rows and the cron summary fixture reopenedOrphans joined SweepSummary in the previous commit; the cron route test's fixture and the PostgREST embed cast in the orphan pass had not followed (check:types caught both). Refs #2062 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv --------- Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + .../sweep/cron/__tests__/route.test.ts | 2 + .../__tests__/answer-flow.test.ts | 4 +- .../__tests__/burst-ack.test.ts | 4 +- .../__tests__/company-question.test.ts | 179 +++++++++++++++++- .../__tests__/graph-api.test.ts | 2 +- .../__tests__/hardening.test.ts | 16 +- .../__tests__/link-routes.test.ts | 81 +++++++- .../whatsapp-inbox/__tests__/linking.test.ts | 18 ++ .../__tests__/process-inbound.test.ts | 53 +++++- .../whatsapp-inbox/__tests__/sweep.test.ts | 75 ++++++++ .../__tests__/webhook-post.test.ts | 26 ++- extensions/general/whatsapp-inbox/index.ts | 43 ++++- .../whatsapp-inbox/lib/company-question.ts | 130 ++++++++++++- .../whatsapp-inbox/lib/conversation.ts | 21 +- .../general/whatsapp-inbox/lib/graph-api.ts | 20 +- .../general/whatsapp-inbox/lib/linking.ts | 23 ++- .../general/whatsapp-inbox/lib/messages.ts | 25 +++ .../whatsapp-inbox/lib/process-inbound.ts | 40 ++-- .../general/whatsapp-inbox/lib/sweep.ts | 58 +++++- 20 files changed, 751 insertions(+), 70 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index f45bac6e..5a5b26fe 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1623,5 +1623,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-06] Recurring invoice month phase (yearly in February, quarterly Feb/May/Aug/Nov) is exposed as a first/next invoice date (start_date on create, next_run_date on update; web dialog + MCP), not the reporter's "months offset" dropdown: an offset is a derived value relative to now that changes meaning when the interval changes, while the date maps one-to-one onto the next_run_date column that already anchors the phase, so no migration and no per-interval range rules. A date off the day_of_month grid is refused (400) instead of normalized, because the cron advances from the due date and an off-grid first run would drift back to day_of_month on the second run. [2026-09-06] Voucher series names live on the existing Verifikationsserier list in settings, not a separate group: the list already enumerates the letters in use, and a name belongs next to the letter it names. Rows are the union of used, configured and named letters so a freshly assigned series can be named before its first verifikat. [2026-09-06] Declined the request to import only SIE accounts with IB, UB or saldo <> 0: an inactive account is a harmless row in the chart, and dropping accounts breaks re-imports of later years that reference them. The chart imports whole. +[2026-09-06] #2062 WhatsApp residuals: a send that THREW (timeout) rolls the company question back instead of resending as numbered text, because Meta may already have delivered it; only an HTTP rejection falls back. The two drains got the 30-day media cutoff plus a one-line M20 notice, and a code whose lookup failed gets a neutral M21 retry instead of M2: both are new bot copy written without a founder sign-off, so the wording is his to change, but silent expiry was the exact destroy-work pattern the #1992 review named. [2026-09-06] Issue #2057 (pre-#1990 stranded rows, is_business = true with no verifikat anchor, invisible in Att bokfora): repaired through a service_role-only RPC repair_stranded_transactions(p_company_id, p_dry_run, p_skip_locked, p_actor) driven by scripts/repair-stranded-categorized-transactions.ts, not a script-side UPDATE and not a worklist predicate change. PostgREST cannot re-assert the three NOT EXISTS anchor legs inside an UPDATE, so a select-then-update script could clobber a row booked in between; the RPC re-checks is_transaction_booked() in the same statement and writes one BankTransactionStrandedRepaired behandlingshistorik event per row in the same transaction. Widening the att-bokfora predicate to "not booked" was rejected: is_business IS NULL is load-bearing (lockPeriod guard, badges, idx_transactions_company_unbooked, RPCs, MCP), the 2026-08-27 decision chose fail-closed over a fourth state, and a predicate change would resurface every company's rows at once with no per-company decision and no audit record. The write is dry-run by default, needs one company id plus an actor, skips sandbox companies unless asked, and skips rows in locked or closed periods unless asked (Swedish review on PR #2350: a row returned to Att bokfora there cannot be booked in place, BFL 5 kap 5 §, so reopening it for triage is an explicit choice). The prod run waits for the founder's go per company. [2026-09-06] reverseEntry releases the bank rows of a reversed verifikat through one RPC, release_reversed_entry_transactions (migration 20260906172540): a single data-modifying CTE nulls the pointer column (journal_entry_id, is_business, category, reconciliation_method) of every transaction that pointed at the reversed entry AND drops those rows' transaction_voucher_links to other verifikat (a residual booking's role 'other' anchor to the small residual verifikat, #2061, Option B of the issue). Chosen over teaching each role-blind gate (bulk_book_transactions' junction EXISTS, fetchJunctionLinkedTxIds behind the unmatched list, is_transaction_booked(), the reconciliation bridge) the bank_line-only rule: that forks the meaning of "booked" per surface and keeps counting a row as matched while the ledger explains a few kronor of it. First cut was two PostgREST statements (read ids, reset, delete); CodeRabbit's point that a failed read or a link created between the reset and the delete recreates the half-anchored row was right, so the reset and the drop moved into one statement under the UPDATE's row locks. SECURITY INVOKER so RLS and the writer-role trigger apply exactly as to the direct statements. Links to the reversed entry itself stay with the engine's junction cleanup (bulk-book N=1 writes a pointer and a bank_line row to the same entry). The residual verifikat stays posted and surfaces as unmatched, which is honest because its main sibling is gone. The 2026-08-29 bank_line-only re-booking guards stay as defense for rows left behind earlier; measured 2026-09-06 on prod, zero non-bank_line junction rows exist, so nothing needs repair. transaction_voucher_links is a mutable reconciliation index (ON DELETE CASCADE from both sides, plain member delete policy, koppla-bort deletes it freely), not rakenskapsinformation, so a hard delete does not touch the BFL audit chain. diff --git a/app/api/extensions/whatsapp-inbox/sweep/cron/__tests__/route.test.ts b/app/api/extensions/whatsapp-inbox/sweep/cron/__tests__/route.test.ts index b03ab3d7..4fcadf85 100644 --- a/app/api/extensions/whatsapp-inbox/sweep/cron/__tests__/route.test.ts +++ b/app/api/extensions/whatsapp-inbox/sweep/cron/__tests__/route.test.ts @@ -80,6 +80,7 @@ describe('GET /api/extensions/whatsapp-inbox/sweep/cron', () => { expiredQuestions: 1, clearedPins: 0, outboundFailed24h: 3, + reopenedOrphans: 0, }) const response = await GET(makeRequest()) @@ -96,6 +97,7 @@ describe('GET /api/extensions/whatsapp-inbox/sweep/cron', () => { expiredQuestions: 1, clearedPins: 0, outboundFailed24h: 3, + reopenedOrphans: 0, }) }) }) diff --git a/extensions/general/whatsapp-inbox/__tests__/answer-flow.test.ts b/extensions/general/whatsapp-inbox/__tests__/answer-flow.test.ts index 130eafa2..ff51fc9d 100644 --- a/extensions/general/whatsapp-inbox/__tests__/answer-flow.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/answer-flow.test.ts @@ -8,7 +8,7 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => { >('@/extensions/general/whatsapp-inbox/lib/graph-api') return { ...actual, - sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), + sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }), markReadWithTyping: vi.fn().mockResolvedValue(undefined), downloadMedia: vi.fn(), } @@ -121,7 +121,7 @@ const openItemContext = (type: 'representation' | 'context') => ({ describe('answer flow (text rows through processInboundMessage)', () => { beforeEach(() => { vi.clearAllMocks() - sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) + sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }) agentRateMock.mockResolvedValue({ ok: true }) interpretMock.mockResolvedValue({ ok: false }) }) diff --git a/extensions/general/whatsapp-inbox/__tests__/burst-ack.test.ts b/extensions/general/whatsapp-inbox/__tests__/burst-ack.test.ts index 1f4aa85b..997c23e9 100644 --- a/extensions/general/whatsapp-inbox/__tests__/burst-ack.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/burst-ack.test.ts @@ -8,7 +8,7 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => { >('@/extensions/general/whatsapp-inbox/lib/graph-api') return { ...actual, - sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), + sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }), markReadWithTyping: vi.fn().mockResolvedValue(undefined), downloadMedia: vi.fn(), } @@ -105,7 +105,7 @@ const RESTAURANT_RECEIPT = { describe('finalizeBurst', () => { beforeEach(() => { vi.clearAllMocks() - sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) + sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }) }) it('a lost pending_ack claim sends nothing (single-winner semantics)', async () => { diff --git a/extensions/general/whatsapp-inbox/__tests__/company-question.test.ts b/extensions/general/whatsapp-inbox/__tests__/company-question.test.ts index b3b447be..17fed074 100644 --- a/extensions/general/whatsapp-inbox/__tests__/company-question.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/company-question.test.ts @@ -8,9 +8,9 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => { >('@/extensions/general/whatsapp-inbox/lib/graph-api') return { ...actual, - sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), - sendReplyButtons: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), - sendList: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), + sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }), + sendReplyButtons: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }), + sendList: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }), } }) @@ -24,10 +24,13 @@ import { import { askCompanyQuestion, applyCompanyChoice, + drainParkedRows, } from '@/extensions/general/whatsapp-inbox/lib/company-question' import { + COMPANY_CHOICE_EXPIRED, NO_COMPANY_OPTIONS, STAGED_AWAITING_COMPANY, + STAGED_MEDIA_MAX_AGE_MS, } from '@/extensions/general/whatsapp-inbox/lib/conversation' import { TEMPLATE } from '@/extensions/general/whatsapp-inbox/lib/messages' @@ -141,6 +144,7 @@ describe('askCompanyQuestion', () => { wamid: null, errorDetail: 'Send failed (HTTP 400): {"error":{"message":"(#131009) Parameter value is not valid","error_data":{"details":"Duplicate button title"}}}', + failure: 'http_rejected', }) const { supabase, enqueue, findCalls } = createQueuedMockSupabase() enqueue({ data: memberships(3) }) @@ -169,7 +173,7 @@ describe('askCompanyQuestion', () => { }) it('falls back to the numbered text question when the list send is rejected', async () => { - sendListMock.mockResolvedValueOnce({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 400)' }) + sendListMock.mockResolvedValueOnce({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 400)', failure: 'http_rejected' }) const { supabase, enqueue, findCalls } = createQueuedMockSupabase() enqueue({ data: memberships(5) }) enqueue({ data: companies(5) }) @@ -193,8 +197,8 @@ describe('askCompanyQuestion', () => { }) it('rolls back only when the numbered fallback also fails', async () => { - sendButtonsMock.mockResolvedValueOnce({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 400)' }) - sendTextMock.mockResolvedValueOnce({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)' }) + sendButtonsMock.mockResolvedValueOnce({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 400)', failure: 'http_rejected' }) + sendTextMock.mockResolvedValueOnce({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)', failure: 'http_rejected' }) const { supabase, enqueue, findCalls } = createQueuedMockSupabase() enqueue({ data: memberships(3) }) enqueue({ data: companies(3) }) @@ -230,6 +234,47 @@ describe('askCompanyQuestion', () => { expect(updates[1].context?.pending_question).toBeUndefined() }) + it('does NOT resend on a transport error: Meta may have delivered the interactive question', async () => { + // #2062 residual 2: a timeout is not a rejection. The interactive message + // may already be on the phone, so a numbered-text resend risks two open + // questions. Roll back instead; the next receipt re-asks. + sendButtonsMock.mockResolvedValueOnce({ + ok: false, + wamid: null, + errorDetail: 'Send errored: WhatsApp send timed out after 10000ms', + failure: 'transport_error', + }) + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: memberships(3) }) + enqueue({ data: companies(3) }) + enqueue({ data: [{ id: 'conv-1' }] }) // guarded transition won + enqueue({ + data: [ + { + ...(makeConversation() as Record), + state: 'awaiting_company', + context: { company_options: [{ id: 'company-1', name: 'Bolag A AB' }] }, + }, + ], + }) // rollback echo + + const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, { + conversation: makeConversation(), + link: makeLink(), + to: '46701234567', + replyBase, + stagedCount: 1, + }) + + expect(asked).toBe('not_asked') + expect(sendButtonsMock).toHaveBeenCalledTimes(1) + expect(sendTextMock).not.toHaveBeenCalled() + const updates = findCalls('whatsapp_conversations', 'update').map( + (args) => args[0] as { state?: string }, + ) + expect(updates.map((u) => u.state)).toEqual(['awaiting_company', 'idle']) + }) + it('uses a list message for 4-10 companies', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: memberships(5) }) @@ -388,6 +433,7 @@ describe('applyCompanyChoice', () => { enqueue({ data: { company_id: 'company-2' } }) // membership check enqueue({ data: null }) // conversation update enqueue({ data: null }) // link last_company_id update + enqueue({ data: [] }) // expiry stamp: nothing past the media window enqueue({ data: [{ id: 'stg-1' }, { id: 'stg-2' }] }) // staged reopen const before = Date.now() @@ -434,7 +480,7 @@ describe('applyCompanyChoice', () => { expect(confirm.body).toContain('byt') // Reopen targeted exactly the staged marker. - const reopenPatch = findCalls('whatsapp_messages', 'update')[0][0] as Record + const reopenPatch = findCalls('whatsapp_messages', 'update')[1][0] as Record expect(reopenPatch.processing_status).toBe('received') const { calls } = { calls: findCalls('whatsapp_messages', 'eq') } expect(calls.some((args) => args[0] === 'error_message' && args[1] === STAGED_AWAITING_COMPANY)).toBe(true) @@ -531,6 +577,125 @@ describe('applyCompanyChoice', () => { }) }) +describe('drainParkedRows', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('stamps rows past the media window expired and re-opens only the rest', async () => { + const { supabase, enqueue, calls } = createQueuedMockSupabase() + enqueue({ data: [{ id: 'old-1' }, { id: 'old-2' }] }) // expiry stamp + enqueue({ data: [{ id: 'stg-1' }] }) // reopen + + const before = Date.now() + const drained = await drainParkedRows(supabase as unknown as SupabaseClient, 'conv-1') + expect(drained).toEqual({ reopenedIds: ['stg-1'], expiredCount: 2, failed: false }) + + const updates = calls.filter((c) => c.table === 'whatsapp_messages' && c.method === 'update') + expect(updates[0].args[0]).toEqual({ error_message: COMPANY_CHOICE_EXPIRED }) + expect(updates[1].args[0]).toEqual({ processing_status: 'received', error_message: null }) + // Both writes are guarded on the staged marker and split on ONE cutoff. + const staged = calls.filter( + (c) => c.method === 'eq' && c.args[0] === 'error_message' && c.args[1] === STAGED_AWAITING_COMPANY, + ) + expect(staged).toHaveLength(2) + const lt = calls.find((c) => c.method === 'lt' && c.args[0] === 'created_at') + const gte = calls.find((c) => c.method === 'gte' && c.args[0] === 'created_at') + expect(lt?.args[1]).toBe(gte?.args[1]) + const cutoffAge = before - new Date(lt!.args[1] as string).getTime() + expect(Math.abs(cutoffAge - STAGED_MEDIA_MAX_AGE_MS)).toBeLessThan(5_000) + }) + + it('a failed expiry stamp still re-opens the recoverable rows and reports failed', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ error: { message: 'canceling statement due to statement timeout' } }) // expiry stamp + enqueue({ data: [{ id: 'stg-1' }] }) // reopen + + const drained = await drainParkedRows(supabase as unknown as SupabaseClient, 'conv-1') + expect(drained).toEqual({ reopenedIds: ['stg-1'], expiredCount: 0, failed: true }) + }) + + it('a failed re-open reports failed with nothing re-opened', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [] }) // expiry stamp + enqueue({ error: { message: 'connection reset' } }) // reopen + + const drained = await drainParkedRows(supabase as unknown as SupabaseClient, 'conv-1') + expect(drained).toEqual({ reopenedIds: [], expiredCount: 0, failed: true }) + }) + + it('applyCompanyChoice keeps the answer applied when the drain fails (sweep retries the rows)', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { company_id: 'company-2' } }) // membership check + enqueue({ data: null }) // conversation update + enqueue({ data: null }) // link last_company_id update + enqueue({ data: [] }) // expiry stamp + enqueue({ error: { message: 'connection reset' } }) // reopen failed + + const applied = await applyCompanyChoice(supabase as unknown as SupabaseClient, { + conversation: makeConversation({ + state: 'awaiting_company', + context: { + company_options: [ + { id: 'company-1', name: 'Bolag A AB' }, + { id: 'company-2', name: 'Bolag B AB' }, + ], + }, + }), + link: makeLink(), + choice: { digit: 2 }, + via: 'numbered', + to: '46701234567', + replyBase, + }) + + expect(applied.ok).toBe(true) + if (!applied.ok) throw new Error('expected the choice to apply') + expect(applied.stagedMessageIds).toEqual([]) + // The pin write stands: the answer is not undone by a failed re-open. + const pin = findCalls('whatsapp_conversations', 'update')[0][0] as { company_id: string } + expect(pin.company_id).toBe('company-2') + expect(sendTextMock).toHaveBeenCalledTimes(1) + expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m6CompanyConfirm) + }) + + it('applyCompanyChoice tells the sender once about receipts that could not be recovered', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { company_id: 'company-2' } }) // membership check + enqueue({ data: null }) // conversation update + enqueue({ data: null }) // link last_company_id update + enqueue({ data: [{ id: 'old-1' }, { id: 'old-2' }, { id: 'old-3' }] }) // expiry stamp + enqueue({ data: [{ id: 'stg-1' }] }) // reopen + + const applied = await applyCompanyChoice(supabase as unknown as SupabaseClient, { + conversation: makeConversation({ + state: 'idle', + context: { + company_options: [ + { id: 'company-1', name: 'Bolag A AB' }, + { id: 'company-2', name: 'Bolag B AB' }, + ], + }, + }), + link: makeLink(), + choice: { digit: 2 }, + via: 'numbered', + to: '46701234567', + replyBase, + }) + + expect(applied.ok).toBe(true) + if (!applied.ok) throw new Error('expected the choice to apply') + expect(applied.stagedMessageIds).toEqual(['stg-1']) + expect(sendTextMock).toHaveBeenCalledTimes(2) + expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m6CompanyConfirm) + const notice = sendTextMock.mock.calls[1][1] + expect(notice.template).toBe(TEMPLATE.m20ReceiptsExpired) + expect(notice.body).toContain('3') + expect(notice.body).toContain('30 dagar') + }) +}) + describe('truncateTitle', () => { it('keeps short titles untouched and cuts long ones cleanly at a word boundary', () => { expect(truncateTitle('Bolag AB', 20)).toBe('Bolag AB') diff --git a/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts b/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts index 3884f5b1..ef746ab0 100644 --- a/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/graph-api.test.ts @@ -49,7 +49,7 @@ describe('graph-api', () => { senderPhoneHash: 'hash-1', }) - expect(result).toEqual({ ok: true, wamid: 'wamid.OUT1', errorDetail: null }) + expect(result).toEqual({ ok: true, wamid: 'wamid.OUT1', errorDetail: null, failure: null }) const [row] = findCall('whatsapp_messages', 'insert') as [Record] expect(row.direction).toBe('outbound') expect(row.wamid).toBe('wamid.OUT1') diff --git a/extensions/general/whatsapp-inbox/__tests__/hardening.test.ts b/extensions/general/whatsapp-inbox/__tests__/hardening.test.ts index e1740594..bb298f85 100644 --- a/extensions/general/whatsapp-inbox/__tests__/hardening.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/hardening.test.ts @@ -18,9 +18,9 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => { >('@/extensions/general/whatsapp-inbox/lib/graph-api') return { ...actual, - sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), - sendReplyButtons: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), - sendList: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), + sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }), + sendReplyButtons: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }), + sendList: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }), markReadWithTyping: vi.fn().mockResolvedValue(undefined), downloadMedia: vi.fn(), } @@ -123,8 +123,8 @@ beforeEach(() => { vi.clearAllMocks() process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper' process.env.WHATSAPP_PHONE_ENCRYPTION_KEY = 'a'.repeat(64) - sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) - sendButtonsMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) + sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }) + sendButtonsMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }) }) describe('company question is not one-shot when the send fails', () => { @@ -140,10 +140,10 @@ describe('company question is not one-shot when the send fails', () => { } it('rolls the question back so the next receipt re-asks', async () => { - sendButtonsMock.mockResolvedValue({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)' }) + sendButtonsMock.mockResolvedValue({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)', failure: 'http_rejected' }) // The numbered text fallback (#1589) must fail too before anything rolls // back; with the default ok:true text mock the question would stay armed. - sendTextMock.mockResolvedValue({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)' }) + sendTextMock.mockResolvedValue({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)', failure: 'http_rejected' }) const mock = createQueuedMockSupabase() enqueueAsk(mock) mock.enqueue({ @@ -262,7 +262,7 @@ describe('combined ack', () => { }) it('leaves the rows unacked when the send failed so the sweep retries', async () => { - sendTextMock.mockResolvedValue({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)' }) + sendTextMock.mockResolvedValue({ ok: false, wamid: null, errorDetail: 'Send failed (HTTP 500)', failure: 'http_rejected' }) const mock = createQueuedMockSupabase() enqueueBurst(mock, { documentKind: 'receipt', diff --git a/extensions/general/whatsapp-inbox/__tests__/link-routes.test.ts b/extensions/general/whatsapp-inbox/__tests__/link-routes.test.ts index 73eff77f..40e48bac 100644 --- a/extensions/general/whatsapp-inbox/__tests__/link-routes.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/link-routes.test.ts @@ -21,7 +21,7 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => { >('@/extensions/general/whatsapp-inbox/lib/graph-api') return { ...actual, - sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), + sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }), getDisplayPhoneNumber: vi.fn().mockResolvedValue(null), } }) @@ -166,3 +166,82 @@ describe('GET /link', () => { expect(vi.mocked(createServiceClient)).not.toHaveBeenCalled() }) }) + +describe('POST /link/default-company', () => { + const route = findRoute('POST', '/link/default-company') + + function jsonRequest(body: unknown): Request { + return new Request('http://localhost:3000/api/extensions/ext/whatsapp-inbox/link/default-company', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + } + const COMPANY = '11111111-1111-4111-8111-111111111111' + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('401s without an authenticated context', async () => { + const response = await route.handler(jsonRequest({ companyId: COMPANY })) + expect(response.status).toBe(401) + }) + + it('400s on a malformed body', async () => { + const { supabase } = createQueuedMockSupabase() + const response = await route.handler(jsonRequest({ companyId: 'not-a-uuid' }), makeCtx(supabase)) + expect(response.status).toBe(400) + }) + + it('checks LIVE membership: the same archived filter intake applies (#2062)', async () => { + const { supabase, enqueue, findCalls, calls } = createQueuedMockSupabase() + enqueue({ data: { company_id: COMPANY } }) // live membership + enqueue({ data: null }) // link update + + const response = await route.handler(jsonRequest({ companyId: COMPANY }), makeCtx(supabase)) + expect(response.status).toBe(200) + expect(findCalls('company_members', 'select')[0][0]).toContain('companies!inner(archived_at)') + expect(findCalls('company_members', 'is')).toContainEqual(['companies.archived_at', null]) + expect( + calls.some( + (c) => + c.table === 'company_members' && + c.method === 'eq' && + c.args[0] === 'user_id' && + c.args[1] === 'user-1', + ), + ).toBe(true) + const [patch] = findCalls('whatsapp_phone_links', 'update')[0] as [Record] + expect(patch).toEqual({ default_company_id: COMPANY }) + }) + + it('403s when the company is archived or the caller is not a member', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: null }) // filtered out by the archived join + + const response = await route.handler(jsonRequest({ companyId: COMPANY }), makeCtx(supabase)) + expect(response.status).toBe(403) + expect(findCalls('whatsapp_phone_links', 'update')).toHaveLength(0) + }) + + it('500s on a failed membership read instead of reading it as "not a member"', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ error: { message: 'connection reset' } }) + + const response = await route.handler(jsonRequest({ companyId: COMPANY }), makeCtx(supabase)) + expect(response.status).toBe(500) + expect(findCalls('whatsapp_phone_links', 'update')).toHaveLength(0) + }) + + it('clears the default without any membership check', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: null }) // link update + + const response = await route.handler(jsonRequest({ companyId: null }), makeCtx(supabase)) + expect(response.status).toBe(200) + expect(findCalls('company_members', 'select')).toHaveLength(0) + const [patch] = findCalls('whatsapp_phone_links', 'update')[0] as [Record] + expect(patch).toEqual({ default_company_id: null }) + }) +}) diff --git a/extensions/general/whatsapp-inbox/__tests__/linking.test.ts b/extensions/general/whatsapp-inbox/__tests__/linking.test.ts index 1d537fa7..adb3a214 100644 --- a/extensions/general/whatsapp-inbox/__tests__/linking.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/linking.test.ts @@ -204,6 +204,24 @@ describe('linking', () => { expect(findCalls('whatsapp_link_codes', 'update')).toHaveLength(0) }) + it('reports a failed lookup as transient, not as a bad code', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ error: { message: 'connection reset' } }) + + const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF') + expect(result).toBe('transient_error') + expect(findCalls('whatsapp_link_codes', 'update')).toHaveLength(0) + }) + + it('reports a failed claim as transient: the code stays claimable', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'code-1', user_id: 'user-1', expires_at: futureExpiry(), used_at: null } }) + enqueue({ error: { message: 'canceling statement due to statement timeout' } }) + + const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF') + expect(result).toBe('transient_error') + }) + it('short-circuits on non-code text without any DB call', async () => { const { supabase, calls } = createQueuedMockSupabase() const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'hej!') diff --git a/extensions/general/whatsapp-inbox/__tests__/process-inbound.test.ts b/extensions/general/whatsapp-inbox/__tests__/process-inbound.test.ts index e1e19e88..e45bc0c7 100644 --- a/extensions/general/whatsapp-inbox/__tests__/process-inbound.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/process-inbound.test.ts @@ -8,7 +8,7 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => { >('@/extensions/general/whatsapp-inbox/lib/graph-api') return { ...actual, - sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), + sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }), markReadWithTyping: vi.fn().mockResolvedValue(undefined), downloadMedia: vi.fn(), } @@ -149,7 +149,7 @@ function lastUpdate(findCalls: (table: string, method: string) => unknown[][]): describe('processInboundMessage (media intake)', () => { beforeEach(() => { vi.clearAllMocks() - sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) + sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }) askCompanyQuestionMock.mockResolvedValue('asked') rateLimitMock.mockResolvedValue({ ok: true }) downloadMediaMock.mockResolvedValue({ @@ -185,6 +185,7 @@ describe('processInboundMessage (media intake)', () => { enqueue({ data: makeLink() }) // load link enqueue({ data: makeConversation() }) // load conversation enqueue({ data: [{ company_id: 'company-1' }] }) // sole membership + enqueue({ data: [] }) // drain: nothing expired enqueue({ data: [] }) // drain: nothing parked behind an old company question enqueue({ data: null }) // sha256 dup check: none enqueue({ data: null }) // item channel_context load @@ -241,6 +242,7 @@ describe('processInboundMessage (media intake)', () => { enqueue({ data: makeLink() }) // load link enqueue({ data: makeConversation() }) // load conversation enqueue({ data: [{ company_id: 'company-1' }] }) // sole live membership + enqueue({ data: [] }) // drain: nothing past the media window enqueue({ data: [{ id: 'stg-1' }, { id: 'stg-2' }] }) // drain: two rows were parked enqueue({ data: null }) // sha256 dup check: none enqueue({ data: null }) // item channel_context load @@ -267,6 +269,40 @@ describe('processInboundMessage (media intake)', () => { kickClient.current = null }) + it('single-company drain leaves month-old receipts expired and says so once', async () => { + const kick = createQueuedMockSupabase() + kickClient.current = kick.supabase + kick.enqueue({ data: null }) + + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: makeRow() }) // load row + enqueue({ data: { id: 'msg-1' } }) // claim + enqueue({ data: makeLink() }) // load link + enqueue({ data: makeConversation() }) // load conversation + enqueue({ data: [{ company_id: 'company-1' }] }) // sole live membership + enqueue({ data: [{ id: 'old-1' }] }) // drain: one row past the media window + enqueue({ data: [{ id: 'stg-1' }] }) // drain: one row re-opened + enqueue({ data: null }) // sha256 dup check: none + enqueue({ data: null }) // item channel_context load + enqueue({ data: null }) // item channel_context update + enqueue({ data: null }) // final markStatus done + + const outcome = await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1') + expect(outcome).toEqual({ kind: 'media_processed', conversationId: 'conv-1' }) + + const patches = findCalls('whatsapp_messages', 'update').map( + (args) => args[0] as Record, + ) + expect(patches.some((p) => p.error_message === 'company_choice_expired')).toBe(true) + const notice = sendTextMock.mock.calls.find((c) => c[1].template === TEMPLATE.m20ReceiptsExpired) + expect(notice).toBeTruthy() + expect(notice![1].body).toContain('30 dagar') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(kick.findCalls('whatsapp_messages', 'eq')).toContainEqual(['id', 'stg-1']) + expect(kick.findCalls('whatsapp_messages', 'eq')).not.toContainEqual(['id', 'old-1']) + kickClient.current = null + }) + it('does not drain when the company came from a pin or a default (the question is not dead)', async () => { const { supabase, enqueue, findCalls } = createQueuedMockSupabase() enqueue({ data: makeRow() }) @@ -308,6 +344,7 @@ describe('processInboundMessage (media intake)', () => { enqueue({ data: makeLink() }) // load link enqueue({ data: makeConversation({ state: 'awaiting_company', context: deadQuestion }) }) enqueue({ data: [{ company_id: 'company-1' }] }) // sole live membership (company-2 archived) + enqueue({ data: [] }) // drain: nothing past the media window enqueue({ data: [{ id: 'stg-1' }] }) // drain: one row was parked enqueue({ data: [makeConversation({ state: 'idle', context: { budget: deadQuestion.budget } })] }) // guarded clear won enqueue({ data: null }) // sha256 dup check: none @@ -346,6 +383,7 @@ describe('processInboundMessage (media intake)', () => { }), }) enqueue({ data: [{ company_id: 'company-1' }] }) // sole live membership + enqueue({ data: [] }) // drain: nothing past the media window enqueue({ data: [] }) // drain: the parked rows already expired enqueue({ data: [makeConversation()] }) // guarded clear won enqueue({ data: null }) // dup check @@ -376,6 +414,7 @@ describe('processInboundMessage (media intake)', () => { }), }) enqueue({ data: [{ company_id: 'company-1' }] }) // sole live membership + enqueue({ data: [] }) // drain: nothing expired enqueue({ data: [] }) // drain: nothing parked enqueue({ data: null }) // dup check enqueue({ data: null }) // item context load @@ -587,6 +626,7 @@ describe('processInboundMessage (media intake)', () => { enqueue({ data: makeLink() }) enqueue({ data: makeConversation() }) enqueue({ data: [{ company_id: 'company-1' }] }) + enqueue({ data: [] }) // drain: nothing expired enqueue({ data: [] }) // drain: nothing parked behind an old company question enqueue({ data: null }) // M17 notice check: none sent yet enqueue({ data: null }) // markStatus skipped @@ -611,6 +651,7 @@ describe('processInboundMessage (media intake)', () => { enqueue({ data: makeLink() }) enqueue({ data: makeConversation() }) enqueue({ data: [{ company_id: 'company-1' }] }) + enqueue({ data: [] }) // drain: nothing expired enqueue({ data: [] }) // drain: nothing parked behind an old company question enqueue({ data: { id: 'earlier-m17' } }) // notice already sent enqueue({ data: null }) // markStatus skipped @@ -627,6 +668,7 @@ describe('processInboundMessage (media intake)', () => { enqueue({ data: makeLink() }) enqueue({ data: makeConversation() }) enqueue({ data: [{ company_id: 'company-1' }] }) + enqueue({ data: [] }) // drain: nothing expired enqueue({ data: [] }) // drain: nothing parked behind an old company question enqueue({ data: null }) // no inbox item for this message yet enqueue({ data: { id: 'existing-doc' } }) // dup found @@ -666,6 +708,7 @@ describe('processInboundMessage (media intake)', () => { }), }) enqueue({ data: [{ company_id: 'company-1' }] }) + enqueue({ data: [] }) // drain: nothing expired enqueue({ data: [] }) // drain: nothing parked behind an old company question enqueue({ data: null }) // dup check enqueue({ data: null }) // new item context load @@ -711,6 +754,7 @@ describe('processInboundMessage (media intake)', () => { enqueue({ data: makeLink() }) enqueue({ data: makeConversation() }) enqueue({ data: [{ company_id: 'company-1' }] }) + enqueue({ data: [] }) // drain: nothing expired enqueue({ data: [] }) // drain: nothing parked behind an old company question enqueue({ data: null }) // no inbox item yet enqueue({ data: null }) // markStatus error @@ -736,6 +780,7 @@ describe('processInboundMessage (media intake)', () => { enqueue({ data: makeLink() }) enqueue({ data: makeConversation() }) enqueue({ data: [{ company_id: 'company-1' }] }) + enqueue({ data: [] }) // drain: nothing expired enqueue({ data: [] }) // drain: nothing parked behind an old company question enqueue({ data: null }) // no inbox item yet enqueue({ data: null }) // markStatus error @@ -756,6 +801,7 @@ describe('processInboundMessage (media intake)', () => { enqueue({ data: makeLink() }) enqueue({ data: makeConversation() }) enqueue({ data: [{ company_id: 'company-1' }] }) + enqueue({ data: [] }) // drain: nothing expired enqueue({ data: [] }) // drain: nothing parked behind an old company question enqueue({ data: null }) // no inbox item yet enqueue({ data: null }) // markStatus error @@ -774,6 +820,7 @@ describe('processInboundMessage (media intake)', () => { enqueue({ data: makeLink() }) enqueue({ data: makeConversation() }) enqueue({ data: [{ company_id: 'company-1' }] }) + enqueue({ data: [] }) // drain: nothing expired enqueue({ data: [] }) // drain: nothing parked behind an old company question enqueue({ data: { id: 'item-winner' } }) // the winner's item enqueue({ data: null }) // markStatus done @@ -791,7 +838,7 @@ describe('processInboundMessage (media intake)', () => { describe('processInboundMessage: linked-sender silences become replies (#1552)', () => { beforeEach(() => { vi.clearAllMocks() - sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) + sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }) }) it('missing media reference: marks error AND owns the failure with M18 via the link', async () => { diff --git a/extensions/general/whatsapp-inbox/__tests__/sweep.test.ts b/extensions/general/whatsapp-inbox/__tests__/sweep.test.ts index cad0a562..326adbf9 100644 --- a/extensions/general/whatsapp-inbox/__tests__/sweep.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/sweep.test.ts @@ -243,3 +243,78 @@ describe('runSweep', () => { expect(patch.context.pin_source).toBeUndefined() }) }) + +describe('runSweep: orphaned parked rows (#2062)', () => { + beforeEach(() => { + vi.clearAllMocks() + processMock.mockResolvedValue({ kind: 'media_processed', conversationId: 'conv-1' }) + finalizeMock.mockResolvedValue(undefined) + }) + + function enqueuePassesOneToFive(enqueue: (r: { data?: unknown; count?: number | null }) => void) { + enqueue({ data: [] }) // stuck received + enqueue({ data: [] }) // stuck processing + enqueue({ data: [] }) // stale pending_ack + enqueue({ data: [] }) // unacked re-arm + enqueue({ data: [] }) // TTL scan + enqueue({ data: [] }) // pin scan + enqueue({ count: 0 }) // outbound failures + } + + it('re-opens parked rows whose conversation has no open company question and processes them', async () => { + const { supabase, enqueue, findCalls, calls } = createQueuedMockSupabase() + enqueuePassesOneToFive(enqueue) + enqueue({ + data: [ + { conversation_id: 'conv-orphan', conversation: { context: {} } }, // options gone: orphan + { conversation_id: 'conv-orphan', conversation: { context: {} } }, + { + conversation_id: 'conv-open', + conversation: { context: { company_options: [{ id: 'company-1', name: 'A AB' }] } }, + }, // question still open: leave it + ], + }) // orphan scan + enqueue({ data: [] }) // drain conv-orphan: expiry stamp + enqueue({ data: [{ id: 'stg-1' }, { id: 'stg-2' }] }) // drain conv-orphan: reopen + + const summary = await runSweep(supabase as unknown as SupabaseClient) + + expect(summary.reopenedOrphans).toBe(2) + expect(processMock).toHaveBeenCalledWith(supabase, 'stg-1') + expect(processMock).toHaveBeenCalledWith(supabase, 'stg-2') + // Only the orphaned conversation was drained. + const drainedConversations = findCalls('whatsapp_messages', 'eq') + .filter((args) => args[0] === 'conversation_id') + .map((args) => args[1]) + expect(drainedConversations).toEqual(['conv-orphan', 'conv-orphan']) + // The scan keys on the staged marker and an age guard. + expect( + calls.some( + (c) => + c.table === 'whatsapp_messages' && + c.method === 'eq' && + c.args[0] === 'error_message' && + c.args[1] === STAGED_AWAITING_COMPANY, + ), + ).toBe(true) + expect(calls.some((c) => c.method === 'lt' && c.args[0] === 'created_at')).toBe(true) + }) + + it('is a no-op when every parked row still has its question open', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueuePassesOneToFive(enqueue) + enqueue({ + data: [ + { + conversation_id: 'conv-open', + conversation: { context: { company_options: [{ id: 'company-1', name: 'A AB' }] } }, + }, + ], + }) + + const summary = await runSweep(supabase as unknown as SupabaseClient) + expect(summary.reopenedOrphans).toBe(0) + expect(processMock).not.toHaveBeenCalled() + expect(findCalls('whatsapp_messages', 'update')).toHaveLength(0) + }) +}) diff --git a/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts b/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts index a689e2f5..dfe096ee 100644 --- a/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts +++ b/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts @@ -12,7 +12,7 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => { >('@/extensions/general/whatsapp-inbox/lib/graph-api') return { ...actual, - sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }), + sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }), sendReaction: vi.fn().mockResolvedValue(undefined), markReadWithTyping: vi.fn().mockResolvedValue(undefined), downloadMedia: vi.fn(), @@ -115,7 +115,7 @@ describe('POST /webhook', () => { beforeEach(() => { vi.clearAllMocks() - sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }) + sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null, failure: null }) process.env.WHATSAPP_APP_SECRET = SECRET process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper' process.env.WHATSAPP_PHONE_ENCRYPTION_KEY = 'a'.repeat(64) @@ -728,6 +728,25 @@ describe('POST /webhook', () => { expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m2BadCode) }) + it('answers a code it could not CHECK with M21 (retry), never with M2 (bad code)', async () => { + // #2062 residual 5: the quota RPC succeeded but the whatsapp_link_codes + // read failed. The code is untouched and still valid, so "the code is + // wrong" would send a user holding a VALID code back to mint another. + const { enqueue, findCalls } = mockSupabase() + enqueue({ data: null }) // no active link + enqueue({ data: { ok: true } }) // quota ok + enqueue({ error: { message: 'canceling statement due to statement timeout' } }) // code lookup failed + enqueue({ data: null }) // trace row insert + + await route.handler(signedRequest(envelope({ messages: [textMessage('AC-7KP4QF')] }))) + expect(sendTextMock).toHaveBeenCalledTimes(1) + expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m21CodeRetry) + // Nothing was claimed: the same code works on the retry. + expect(findCalls('whatsapp_link_codes', 'update')).toHaveLength(0) + const [row] = findCalls('whatsapp_messages', 'insert')[0] as [Record] + expect(row.error_message).toContain('link code lookup failed') + }) + it('hashLinkCode matches what the webhook looks up', () => { // Regression guard: panel mints, webhook consumes; both must hash alike. expect(hashLinkCode('AC-7KP4QF')).toMatch(/^[0-9a-f]{64}$/) @@ -921,6 +940,7 @@ describe('POST /webhook', () => { mock.enqueue({ data: { company_id: 'company-2' } }) // membership check mock.enqueue({ data: null }) // guarded conversation pin update mock.enqueue({ data: null }) // link last_company_id + mock.enqueue({ data: [] }) // expiry stamp: nothing past the media window mock.enqueue({ data: [{ id: 'stg-1' }, { id: 'stg-2' }] }) // staged reopen mock.enqueue({ data: null }) // terminal row insert @@ -943,6 +963,7 @@ describe('POST /webhook', () => { mock.enqueue({ data: { company_id: 'company-1' } }) // membership check mock.enqueue({ data: null }) // guarded conversation pin update mock.enqueue({ data: null }) // link last_company_id + mock.enqueue({ data: [] }) // expiry stamp: nothing past the media window mock.enqueue({ data: [{ id: 'stg-1' }] }) // staged reopen mock.enqueue({ data: null }) // terminal row insert @@ -1153,6 +1174,7 @@ describe('POST /webhook', () => { mock.enqueue({ data: { company_id: 'company-2' } }) // membership check mock.enqueue({ data: null }) // guarded pin write mock.enqueue({ data: null }) // link last_company_id + mock.enqueue({ data: [] }) // expiry stamp: nothing past the media window mock.enqueue({ data: [{ id: 'stg-1' }] }) // staged reopen mock.enqueue({ data: null }) // terminal row insert diff --git a/extensions/general/whatsapp-inbox/index.ts b/extensions/general/whatsapp-inbox/index.ts index a2756e87..5fd52bbe 100644 --- a/extensions/general/whatsapp-inbox/index.ts +++ b/extensions/general/whatsapp-inbox/index.ts @@ -226,6 +226,28 @@ async function handleUnknownSender( if (msg.type === 'text' && looksLikeLinkCode(msg.text)) { const consumed = await consumeLinkCode(supabase, msg.text ?? '') + if (consumed === 'transient_error') { + // No verdict about the code exists: the lookup itself failed. M2 here + // would tell a user holding a VALID code that it is wrong and send + // them to mint another (#2062). The code stays unconsumed, so the + // honest answer is a neutral "send it again in a moment". In degraded + // mode (quota RPC down too) it sits behind the same fail-closed + // throttle as M2, so a full outage stays silent rather than answering + // every inbound. Trace row first, same dedupe reason as the M2 path. + if (quotaUnavailable && (await badCodeThrottled(supabase, phoneHash))) return + log.warn('link code lookup failed; asking the sender to retry', { degraded: quotaUnavailable }) + const traced = await recordUnknownSenderMessage( + supabase, msg, phoneHash, 'done', `Unknown sender: link code lookup failed, M21 sent${degraded}`, + ) + if (traced === 'duplicate') return + await sendText(supabase, { + to: msg.from, + body: copy.m21CodeRetry(), + template: TEMPLATE.m21CodeRetry, + senderPhoneHash: phoneHash, + }) + return + } // A bad code earns M2. Normally the pre-binding quota bounds M2; in // degraded mode it gets its own small fail-closed throttle instead // (badCodeThrottled: 1 per 10 min, 3 per day), so a sender greeted with @@ -1020,18 +1042,29 @@ export const whatsappInboxExtension: Extension = { return NextResponse.json({ error: 'Ogiltig förfrågan.' }, { status: 400 }) } - // The caller must be a member of the company receipts are routed to: - // otherwise a user could point their intake at someone else's books. + // The caller must be a LIVE member of the company receipts are routed + // to: otherwise a user could point their intake at someone else's + // books. Same filter shape as isMember in process-inbound.ts, which + // is what intake consults: a default pointing at an archived company + // used to save fine here and then be silently ignored there (#2062). if (parsedBody.companyId) { - const { data: membership } = await ctx.supabase + const { data: membership, error: membershipError } = await ctx.supabase .from('company_members') - .select('id') + .select('company_id, companies!inner(archived_at)') .eq('company_id', parsedBody.companyId) .eq('user_id', ctx.userId) + .is('companies.archived_at', null) + .limit(1) .maybeSingle() + if (membershipError) { + return NextResponse.json( + { error: 'Kunde inte kontrollera medlemskapet. Försök igen om en stund.' }, + { status: 500 }, + ) + } if (!membership) { return NextResponse.json( - { error: 'Du är inte medlem i det företaget.' }, + { error: 'Du är inte medlem i det företaget, eller så är det arkiverat.' }, { status: 403 }, ) } diff --git a/extensions/general/whatsapp-inbox/lib/company-question.ts b/extensions/general/whatsapp-inbox/lib/company-question.ts index b169cd40..0ea14c0a 100644 --- a/extensions/general/whatsapp-inbox/lib/company-question.ts +++ b/extensions/general/whatsapp-inbox/lib/company-question.ts @@ -25,9 +25,11 @@ import { } from './graph-api' import { botCopy, TEMPLATE } from './messages' import { + COMPANY_CHOICE_EXPIRED, COMPANY_PIN_TTL_MS, NO_COMPANY_OPTIONS, STAGED_AWAITING_COMPANY, + STAGED_MEDIA_MAX_AGE_MS, getContext, updateConversation, type ConversationContext, @@ -224,11 +226,17 @@ export async function askCompanyQuestion( sent = await sendText(supabase, { ...base, body: numberedBody }) } - if (!sent.ok && interactive) { + if (!sent.ok && interactive && sent.failure === 'http_rejected') { // Meta rejected the interactive payload at send time (no wamid was ever // issued, so nothing reached the phone): ask the same question as plain // numbered text instead of going silent. Same template id, same open // question; only the answer mechanism changes (digit instead of tap). + // ONLY on an HTTP rejection: a transport error (timeout, reset) means + // Meta may have accepted the interactive message before the failure + // surfaced, and a text resend would put a second question on the phone + // (#2062). That case falls through to the rollback below: the next + // receipt re-asks, and a tap on a question that did arrive is at worst + // ignored, which beats two open questions. log.warn('company question interactive send rejected; falling back to numbered text', { conversationId: args.conversation.id, errorDetail: sent.errorDetail, @@ -253,12 +261,109 @@ export async function askCompanyQuestion( }) log.warn('company question send failed; question rolled back', { conversationId: args.conversation.id, + failure: sent.failure, }) return 'not_asked' } return 'asked' } +export interface DrainedParkedRows { + /** Parked rows re-opened for processing (run through the kick). */ + reopenedIds: string[] + /** Parked rows older than Meta's media retention, stamped expired instead. */ + expiredCount: number + /** One of the two updates errored. The rows it should have touched are + * still parked; the sweep's orphan pass re-opens them once the company + * question is closed, so callers log and carry on rather than undoing + * the answer that was already applied. */ + failed: boolean +} + +/** + * Re-open the receipts parked behind a company question, in the ONE shape + * both drains share (the answer path and the single-live-company path). + * + * Rows older than STAGED_MEDIA_MAX_AGE_MS are stamped company_choice_expired + * rather than re-opened: Meta no longer serves their media, so re-opening + * them only ran each one through the MAX_ATTEMPTS error path and an M18 + * about a receipt sent a month ago (#2062). The sweep stamps the same cutoff + * for conversations still in awaiting_company; this covers the idle ones + * (question TTL passed, options kept) that only a drain ever touches again. + * The stamp is guarded on the staged marker, so a second drain finds nothing + * new to expire and the notice goes out once. + */ +export async function drainParkedRows( + supabase: SupabaseClient, + conversationId: string, +): Promise { + const staleCutoff = new Date(Date.now() - STAGED_MEDIA_MAX_AGE_MS).toISOString() + const { data: expired, error: expireError } = await supabase + .from('whatsapp_messages') + .update({ error_message: COMPANY_CHOICE_EXPIRED }) + .eq('conversation_id', conversationId) + .eq('processing_status', 'skipped') + .eq('error_message', STAGED_AWAITING_COMPANY) + .lt('created_at', staleCutoff) + .select('id') + if (expireError) { + log.error('drain: expiry stamp failed; stale rows stay parked for the next drain', expireError, { + conversationId, + }) + } + // Independent of the stamp: a failed stamp must not also withhold the rows + // that ARE recoverable. + const { data: reopened, error: reopenError } = await supabase + .from('whatsapp_messages') + .update({ processing_status: 'received', error_message: null }) + .eq('conversation_id', conversationId) + .eq('processing_status', 'skipped') + .eq('error_message', STAGED_AWAITING_COMPANY) + .gte('created_at', staleCutoff) + .select('id') + if (reopenError) { + log.error('drain: re-open failed; the sweep orphan pass retries it', reopenError, { + conversationId, + }) + } + return { + reopenedIds: ((reopened ?? []) as { id: string }[]).map((r) => r.id), + expiredCount: Array.isArray(expired) ? expired.length : 0, + failed: Boolean(expireError || reopenError), + } +} + +/** + * Tell the sender which parked receipts could not be recovered. Sent at the + * drain, never from the sweep: the drain runs on an inbound message, so the + * 24h service window is open; thirty days after the last receipt it is not, + * and a free-form send would fail. No-op for a count of zero. + * + * Best-effort like every other notice in this channel (M17, M18, M19): a + * failed send is recorded as a failed outbound row by sendText and logged + * here; there is no durable outbound retry in the extension, and the rows + * the notice describes are already terminal. + */ +export async function notifyExpiredParkedRows( + supabase: SupabaseClient, + args: { to: string; replyBase: ReplyBase; expiredCount: number }, +): Promise { + if (args.expiredCount <= 0) return + const sent = await sendText(supabase, { + to: args.to, + body: botCopy('sv').m20ReceiptsExpired({ count: args.expiredCount }), + template: TEMPLATE.m20ReceiptsExpired, + ...args.replyBase, + }) + if (!sent.ok) { + log.warn('expired-receipts notice not delivered', { + conversationId: args.replyBase.conversationId ?? null, + expiredCount: args.expiredCount, + failure: sent.failure, + }) + } +} + export interface AppliedCompanyChoice { ok: true companyId: string @@ -385,18 +490,25 @@ export async function applyCompanyChoice( ...args.replyBase, }) - const { data: reopened } = await supabase - .from('whatsapp_messages') - .update({ processing_status: 'received', error_message: null }) - .eq('conversation_id', args.conversation.id) - .eq('processing_status', 'skipped') - .eq('error_message', STAGED_AWAITING_COMPANY) - .select('id') + const drained = await drainParkedRows(supabase, args.conversation.id) + if (drained.failed) { + // The answer is applied (pin set, options claimed) and stays applied: + // the rows still parked are now orphans (no open question), which the + // sweep re-opens within minutes and resolves against the fresh pin. + log.error('company choice applied but the drain failed; sweep will retry', null, { + conversationId: args.conversation.id, + }) + } + await notifyExpiredParkedRows(supabase, { + to: args.to, + replyBase: args.replyBase, + expiredCount: drained.expiredCount, + }) return { ok: true, companyId, companyName, - stagedMessageIds: ((reopened ?? []) as { id: string }[]).map((r) => r.id), + stagedMessageIds: drained.reopenedIds, } } diff --git a/extensions/general/whatsapp-inbox/lib/conversation.ts b/extensions/general/whatsapp-inbox/lib/conversation.ts index 914cacf2..990acf18 100644 --- a/extensions/general/whatsapp-inbox/lib/conversation.ts +++ b/extensions/general/whatsapp-inbox/lib/conversation.ts @@ -83,9 +83,12 @@ const BAD_CODE_WINDOW_MS = 10 * 60 * 1000 const BAD_CODE_DAY_MS = 24 * 60 * 60 * 1000 const BAD_CODE_DAY_MAX = 3 -/** True when another M2 (bad code) reply to this phone hash would exceed the - * degraded-mode cap. Fails CLOSED: if the window cannot be read, no M2 goes - * out (the caller falls through to the throttled M1 path instead). */ +/** True when another code reply (M2 bad code, or M21 could-not-check) to this + * phone hash would exceed the degraded-mode cap. Both templates share one + * window: they answer the same inbound shape, and counting only M2 let a + * code-shaped flood earn one M21 per message during a lookup outage. + * Fails CLOSED: if the window cannot be read, nothing goes out (the caller + * falls through to the throttled M1 path instead). */ export async function badCodeThrottled( supabase: SupabaseClient, phoneHash: string, @@ -96,7 +99,7 @@ export async function badCodeThrottled( .select('created_at') .eq('direction', 'outbound') .eq('sender_phone_hash', phoneHash) - .eq('raw_payload->>template', TEMPLATE.m2BadCode) + .in('raw_payload->>template', [TEMPLATE.m2BadCode, TEMPLATE.m21CodeRetry]) .gte('created_at', since) .order('created_at', { ascending: false }) .limit(BAD_CODE_DAY_MAX) @@ -120,8 +123,16 @@ export const MAX_QUESTIONS_PER_DAY = 6 /** error_message marker on whatsapp_messages rows parked while the company * question is open. The answer handler re-opens exactly these. */ export const STAGED_AWAITING_COMPANY = 'staged_awaiting_company' -/** Marker after the 48h TTL expired: excluded from any later re-open. */ +/** Marker on a parked row whose media Meta no longer serves: excluded from + * any later re-open. Stamped by the sweep's question-TTL pass and by both + * drains (answer and single-company), all against the same cutoff. */ export const COMPANY_CHOICE_EXPIRED = 'company_choice_expired' +/** Staged receipts stay answerable while Meta still serves their media + * (~30 days). Past that the marker is honest: nothing can recover them. + * ONE definition, read by the sweep and by both drains (#2062): a drain that + * re-opened a row older than this sent it through media download to the + * MAX_ATTEMPTS error path and an M18 about a month-old receipt. */ +export const STAGED_MEDIA_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 /** Terminal marker when the company question could not be asked at all: * the sender has fewer than 2 companies to choose between, so nothing will * change until they fix the linking in the app. The rows are never retried; diff --git a/extensions/general/whatsapp-inbox/lib/graph-api.ts b/extensions/general/whatsapp-inbox/lib/graph-api.ts index ced90eb8..c53fca43 100644 --- a/extensions/general/whatsapp-inbox/lib/graph-api.ts +++ b/extensions/general/whatsapp-inbox/lib/graph-api.ts @@ -66,11 +66,26 @@ export interface SendTextArgs extends SendMessageBase { body: string } +/** + * How a send failed. Two different worlds hide behind ok:false, and callers + * that resend on failure must tell them apart (#2062): + * - http_rejected: Meta answered with a non-2xx status. No wamid was issued + * and nothing reached the phone, so a different payload can safely be + * tried (the numbered-text fallback for a rejected interactive message). + * - transport_error: the request threw (timeout, connection reset). Meta may + * have accepted the message before the failure surfaced, so a resend can + * put a second copy on the phone. Callers treat this as "unknown", never + * as "not delivered". + */ +export type SendFailureKind = 'http_rejected' | 'transport_error' + export interface SendTextResult { ok: boolean wamid: string | null /** Why the send failed, for the outbound row (#1552). Null on success. */ errorDetail: string | null + /** Typed twin of errorDetail for control flow. Null on success. */ + failure: SendFailureKind | null } /** POST one message payload to the Graph API. Never throws. */ @@ -81,6 +96,7 @@ async function postToGraph( let wamid: string | null = null let ok = false let errorDetail: string | null = null + let failure: SendFailureKind | null = null try { const response = await fetchWithTimeout( @@ -105,6 +121,7 @@ async function postToGraph( } else { const detail = await response.text().catch(() => '') errorDetail = `Send failed (HTTP ${response.status}): ${detail.slice(0, 250)}` + failure = 'http_rejected' log.warn('WhatsApp send failed', { status: response.status, template, @@ -114,10 +131,11 @@ async function postToGraph( } catch (err) { const message = err instanceof Error ? err.message : String(err) errorDetail = `Send errored: ${message.slice(0, 250)}` + failure = 'transport_error' log.warn('WhatsApp send errored', { template, error: message }) } - return { ok, wamid, errorDetail } + return { ok, wamid, errorDetail, failure } } /** Persist the outbound message row. Never throws. */ diff --git a/extensions/general/whatsapp-inbox/lib/linking.ts b/extensions/general/whatsapp-inbox/lib/linking.ts index 60aafb48..d672636e 100644 --- a/extensions/general/whatsapp-inbox/lib/linking.ts +++ b/extensions/general/whatsapp-inbox/lib/linking.ts @@ -118,34 +118,47 @@ export async function mintLinkCode( } /** - * Verify + consume a code from an inbound chat message. Returns the owning - * user id, or null for unknown/expired/already-used codes. Single-use is + * Outcome of consuming a code: + * - { userId }: verified and claimed, single-use. + * - null: unknown, expired, already used, or lost the claim race. A verdict + * about the CODE, so the caller may say "bad code" (M2). + * - 'transient_error': the lookup or the claim could not be executed (DB + * error, statement timeout). No verdict at all: the code is untouched and + * still valid, so the caller must not answer M2 (#2062). Same tri-state + * idiom as resolveCompanyTarget in process-inbound.ts. + */ +export type LinkCodeConsumption = { userId: string } | null | 'transient_error' + +/** + * Verify + consume a code from an inbound chat message. Single-use is * enforced by the guarded UPDATE (used_at IS NULL): a concurrent redelivery * loses the race and gets null. */ export async function consumeLinkCode( serviceClient: SupabaseClient, rawText: string, -): Promise<{ userId: string } | null> { +): Promise { const code = normalizeLinkCode(rawText) if (!code) return null - const { data: row } = await serviceClient + const { data: row, error: lookupError } = await serviceClient .from('whatsapp_link_codes') .select('id, user_id, expires_at, used_at') .eq('code_hash', hashLinkCode(code)) .maybeSingle() + if (lookupError) return 'transient_error' if (!row || row.used_at) return null if (new Date(row.expires_at).getTime() < Date.now()) return null - const { data: claimed } = await serviceClient + const { data: claimed, error: claimError } = await serviceClient .from('whatsapp_link_codes') .update({ used_at: new Date().toISOString() }) .eq('id', row.id) .is('used_at', null) .select('id') .maybeSingle() + if (claimError) return 'transient_error' if (!claimed) return null return { userId: row.user_id } diff --git a/extensions/general/whatsapp-inbox/lib/messages.ts b/extensions/general/whatsapp-inbox/lib/messages.ts index dc6d6d2e..9b5c8b47 100644 --- a/extensions/general/whatsapp-inbox/lib/messages.ts +++ b/extensions/general/whatsapp-inbox/lib/messages.ts @@ -57,6 +57,8 @@ export const TEMPLATE = { m17RateLimitedDay: 'm17_rate_limited_day', m18Error: 'm18_error', m19NoCompany: 'm19_no_company', + m20ReceiptsExpired: 'm20_receipts_expired', + m21CodeRetry: 'm21_code_retry', } as const export type TemplateId = (typeof TEMPLATE)[keyof typeof TEMPLATE] @@ -202,6 +204,21 @@ const SV = { // the old silent parking. m19NoCompany: () => 'Jag kunde inte koppla kvittot till något företag. Öppna Accounted och kontrollera WhatsApp-kopplingen under *Inställningar -> WhatsApp*, och skicka sedan kvittot igen.', + + // Receipts parked behind the company question for longer than Meta keeps + // the media. Sent once, at the moment the rest of the parked rows are + // re-opened (the service window is open then, and only then), so the user + // learns which files never made it instead of finding out by absence. + m20ReceiptsExpired: ({ count }: { count: number }) => + count > 1 + ? `${count} äldre kvitton gick inte längre att hämta, WhatsApp sparar filer i cirka 30 dagar. Skicka gärna dem igen.` + : 'Ett äldre kvitto gick inte längre att hämta, WhatsApp sparar filer i cirka 30 dagar. Skicka gärna det igen.', + + // The code could not be CHECKED (a database blip), which is not the same + // as a wrong code: the M2 wording sends a user with a valid code back to + // the panel for a new one. Neutral, and the code stays valid for a retry. + m21CodeRetry: () => + 'Jag kunde inte kontrollera koden just nu. Skicka samma kod igen om en liten stund.', } const EN: typeof SV = { @@ -329,6 +346,14 @@ const EN: typeof SV = { m19NoCompany: () => 'I could not assign the receipt to any company. Open Accounted and check the WhatsApp linking under *Settings -> WhatsApp*, then send the receipt again.', + + m20ReceiptsExpired: ({ count }: { count: number }) => + count > 1 + ? `${count} older receipts could no longer be fetched, WhatsApp keeps files for about 30 days. Please send them again.` + : 'One older receipt could no longer be fetched, WhatsApp keeps files for about 30 days. Please send it again.', + + m21CodeRetry: () => + 'I could not check the code just now. Send the same code again in a moment.', } const COPY: Record = { sv: SV, en: EN } diff --git a/extensions/general/whatsapp-inbox/lib/process-inbound.ts b/extensions/general/whatsapp-inbox/lib/process-inbound.ts index 05fed8d6..1267cc46 100644 --- a/extensions/general/whatsapp-inbox/lib/process-inbound.ts +++ b/extensions/general/whatsapp-inbox/lib/process-inbound.ts @@ -62,7 +62,12 @@ import { type QueuedQuestion, type QuestionType, } from './conversation' -import { askCompanyQuestion, type CompanyChoiceVia } from './company-question' +import { + askCompanyQuestion, + drainParkedRows, + notifyExpiredParkedRows, + type CompanyChoiceVia, +} from './company-question' import { evaluateQuestion, QUESTION_PRIORITY } from './questions' import { interpretChatAnswer } from './interpret-answer' import { appendQuestionHistory, updateItemContext } from './item-context' @@ -535,25 +540,28 @@ async function processMediaMessage( // answered now: the sender's other memberships are archived (or gone), // so that question will not be asked again, and the sole live company // is unambiguous by construction. Re-open them so they file here - // instead of expiring at Meta. Same guarded idiom as applyCompanyChoice; - // the re-run resolves them as 'single' on its own. default/pin - // resolution deliberately does not drain: those choices the user can - // still change, so the open question there is not dead. - const { data: parked } = await supabase - .from('whatsapp_messages') - .update({ processing_status: 'received', error_message: null }) - .eq('conversation_id', conversation.id) - .eq('processing_status', 'skipped') - .eq('error_message', STAGED_AWAITING_COMPANY) - .select('id') - const parkedIds = ((parked ?? []) as { id: string }[]).map((r) => r.id) - if (parkedIds.length > 0) { + // instead of expiring at Meta. Same drain as applyCompanyChoice (one + // definition, including the media-age cutoff); the re-run resolves + // them as 'single' on its own. default/pin resolution deliberately + // does not drain: those choices the user can still change, so the + // open question there is not dead. + const drained = await drainParkedRows(supabase, conversation.id) + if (drained.reopenedIds.length > 0) { log.info('re-opened receipts parked behind an unaskable company question', { conversationId: conversation.id, - count: parkedIds.length, + count: drained.reopenedIds.length, }) - kickInboundProcessing(parkedIds) + kickInboundProcessing(drained.reopenedIds) } + if (drained.failed) { + // Not a reason to keep the dead question: clearing it below is what + // turns the still-parked rows into orphans the sweep re-opens, and + // this path drains again on the sender's next receipt anyway. + log.error('single-company drain failed; rows stay parked for the sweep', null, { + conversationId: conversation.id, + }) + } + await notifyExpiredParkedRows(supabase, { to, replyBase, expiredCount: drained.expiredCount }) // The question itself is as dead as the rows behind it. Left in place, // state 'awaiting_company' turns every typed word into a company_retry // that re-offers the archived company, swallows 'byt', and keeps diff --git a/extensions/general/whatsapp-inbox/lib/sweep.ts b/extensions/general/whatsapp-inbox/lib/sweep.ts index bcc125d8..c0117a00 100644 --- a/extensions/general/whatsapp-inbox/lib/sweep.ts +++ b/extensions/general/whatsapp-inbox/lib/sweep.ts @@ -21,6 +21,8 @@ * 5. Count outbound sends that failed in the last 24h, so the per-minute * "whatsapp sweep complete" log line surfaces delivery problems nothing * else reads (delivery_status is otherwise write-only). + * 6. Re-open parked receipts whose company question is closed but whose + * drain never landed (a failed UPDATE), and process them. */ import type { SupabaseClient } from '@supabase/supabase-js' @@ -30,12 +32,14 @@ import { COMPANY_CHOICE_EXPIRED, QUESTION_TTL_MS, STAGED_AWAITING_COMPANY, + STAGED_MEDIA_MAX_AGE_MS, getContext, resolveRecipient, updateConversation, type ConversationContext, } from './conversation' import { finalizeBurst, processInboundMessage, sendErrorNoticeOnce } from './process-inbound' +import { drainParkedRows } from './company-question' import { appendQuestionHistory, updateItemContext } from './item-context' const log = createLogger('whatsapp-inbox/sweep') @@ -56,9 +60,9 @@ const ACK_STALE_MS = 60 * 1000 const UNACKED_REARM_MS = 120 * 1000 const MAX_ATTEMPTS = 3 const BATCH = 25 -/** Staged receipts stay answerable while Meta still serves their media - * (~30 days). Past that the marker is honest: nothing can recover them. */ -const STAGED_MEDIA_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 +/** A parked row younger than this may simply be waiting for its question to + * be armed (park, then guarded transition): not an orphan yet. */ +const ORPHAN_PARKED_MIN_AGE_MS = 2 * 60 * 1000 export interface SweepSummary { reclaimedReceived: number @@ -71,6 +75,9 @@ export interface SweepSummary { * (Graph send failure or a Meta 'failed' status callback). Observability * only: the sweep log line is the consumer. */ outboundFailed24h: number + /** Parked rows with no open company question left to re-open them, + * re-opened and processed by pass 6. */ + reopenedOrphans: number } interface StuckRow { @@ -141,6 +148,7 @@ export async function runSweep(supabase: SupabaseClient): Promise expiredQuestions: 0, clearedPins: 0, outboundFailed24h: 0, + reopenedOrphans: 0, } const finalizeConversations = new Set() const now = Date.now() @@ -402,5 +410,49 @@ export async function runSweep(supabase: SupabaseClient): Promise log.error('sweep: outbound failure count failed', err) } + // ── 6. Orphaned parked rows ──────────────────────────────── + // A row parked behind the company question whose conversation no longer + // holds an open question (company_options gone) has nobody left to + // re-open it: the drain that should have done so errored (#2062), or the + // answer landed and its re-open UPDATE failed. Run the same drain the + // answer path uses and process what comes back; the re-run resolves each + // row against the fresh pin, the default, or the sole live company, or + // re-asks the question properly. The age guard keeps a row that was parked + // milliseconds before its question was armed out of this pass. + try { + const parkedBefore = new Date(now - ORPHAN_PARKED_MIN_AGE_MS).toISOString() + const { data } = await supabase + .from('whatsapp_messages') + .select('conversation_id, conversation:whatsapp_conversations!inner(context)') + .eq('processing_status', 'skipped') + .eq('error_message', STAGED_AWAITING_COMPANY) + .lt('created_at', parkedBefore) + .limit(BATCH * 4) + const orphaned = new Set() + for (const row of ((data ?? []) as unknown as Array<{ + conversation_id: string | null + conversation: { context: Record | null } | null + }>)) { + if (!row.conversation_id) continue + const options = (row.conversation?.context as ConversationContext | null)?.company_options + if (options && options.length > 0) continue // question still open: not an orphan + orphaned.add(row.conversation_id) + } + for (const conversationId of orphaned) { + const drained = await drainParkedRows(supabase, conversationId) + if (drained.reopenedIds.length === 0) continue + log.info('sweep: re-opened orphaned parked receipts', { + conversationId, + count: drained.reopenedIds.length, + }) + for (const id of drained.reopenedIds) { + await processInboundMessage(supabase, id) + summary.reopenedOrphans++ + } + } + } catch (err) { + log.error('sweep: orphaned parked rows pass failed', err) + } + return summary }