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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1623,5 +1623,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, unknown>),
|
||||
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<string, unknown>
|
||||
const reopenPatch = findCalls('whatsapp_messages', 'update')[1][0] as Record<string, unknown>
|
||||
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')
|
||||
|
||||
@@ -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<string, unknown>]
|
||||
expect(row.direction).toBe('outbound')
|
||||
expect(row.wamid).toBe('wamid.OUT1')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, unknown>]
|
||||
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<string, unknown>]
|
||||
expect(patch).toEqual({ default_company_id: null })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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!')
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
)
|
||||
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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>]
|
||||
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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<DrainedParkedRows> {
|
||||
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<void> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<LinkCodeConsumption> {
|
||||
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 }
|
||||
|
||||
@@ -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<BotLocale, typeof SV> = { sv: SV, en: EN }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<SweepSummary>
|
||||
expiredQuestions: 0,
|
||||
clearedPins: 0,
|
||||
outboundFailed24h: 0,
|
||||
reopenedOrphans: 0,
|
||||
}
|
||||
const finalizeConversations = new Set<string>()
|
||||
const now = Date.now()
|
||||
@@ -402,5 +410,49 @@ export async function runSweep(supabase: SupabaseClient): Promise<SweepSummary>
|
||||
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<string>()
|
||||
for (const row of ((data ?? []) as unknown as Array<{
|
||||
conversation_id: string | null
|
||||
conversation: { context: Record<string, unknown> | 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user