fix(whatsapp): company question falls back to numbered text and ignores archived companies (#1992)

* fix(whatsapp): company question survives a Meta-rejected interactive send (#1589)

The linked multi-company sender in #1589 never heard back because Meta
rejected the reply-button payload synchronously (HTTP 400, #131009
"Duplicate button title"): the sender belongs to two companies with the
same name, one of them archived. askCompanyQuestion rolled the question
back and returned not_asked, the row stayed parked as
staged_awaiting_company, and the channel went silent.

- Exclude archived companies wherever the channel resolves memberships
  (isMember, resolveCompanyTarget, loadCompanyOptions, applyCompanyChoice,
  the M3 greeting count), same inner-join filter as the middleware.
- uniqueTitles: interactive button/row titles are made unique (position
  suffix) so two live same-named companies, or names that truncate to the
  same prefix, no longer trip #131009.
- Numbered-text fallback: when the interactive send is rejected at send
  time, ask the same M6 question as plain numbered text; roll back only
  when that fails too. A typed digit is recorded as via='numbered'.
- Drain: when the sender now resolves as 'single', rows parked behind the
  dead question are re-opened and kicked instead of expiring at Meta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

* fix(whatsapp): single-company drain also clears the dead company question (#1589)

Re-opening the parked rows left the conversation in state awaiting_company
with company_options and the company pending_question intact, so the sender
stayed behind a zombie question for up to 48h: every typed word became a
company_retry re-offering the archived company, 'byt' was swallowed, and
finalizeBurst could not ask about the drained receipts until the TTL sweep.

- After the drain, when a company question is open in any of its shapes
  (awaiting_company state, kept company_options, company pending_question),
  clear it through the guarded updateConversation: state -> idle, options and
  the company pending_question deleted, other question types untouched.
- Tests: the clear in its awaiting_company and post-TTL (idle + options)
  shapes, and its no-op for a representation question.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-27 22:26:38 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 57ff2eda96
commit 273af39994
9 changed files with 566 additions and 24 deletions
@@ -19,6 +19,7 @@ import {
sendReplyButtons,
sendList,
truncateTitle,
uniqueTitles,
} from '@/extensions/general/whatsapp-inbox/lib/graph-api'
import {
askCompanyQuestion,
@@ -93,7 +94,7 @@ describe('askCompanyQuestion', () => {
})
it('uses reply buttons for <=3 companies, ids = company ids', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
const { supabase, enqueue, findCalls, calls } = createQueuedMockSupabase()
enqueue({ data: memberships(3) })
enqueue({ data: companies(3) })
enqueue({ data: [{ id: 'conv-1' }] }) // guarded transition won
@@ -112,6 +113,18 @@ describe('askCompanyQuestion', () => {
expect(args.buttons).toHaveLength(3)
expect(args.buttons[0]).toEqual({ id: 'company-1', title: 'Bolag A AB' })
expect(args.body).toContain('Vilket företag')
// The interactive send succeeded: no numbered fallback, no duplicate question.
expect(sendTextMock).not.toHaveBeenCalled()
// Archived companies are never offered (#1589).
expect(
calls.some(
(c) =>
c.table === 'companies' &&
c.method === 'is' &&
c.args[0] === 'archived_at' &&
c.args[1] === null,
),
).toBe(true)
// State transition stored the options for digit replies too.
const patch = findCalls('whatsapp_conversations', 'update')[0][0] as {
state: string
@@ -122,6 +135,101 @@ describe('askCompanyQuestion', () => {
expect(patch.context.pending_question.type).toBe('company')
})
it('falls back to the numbered text question when the reply-button send is rejected', async () => {
sendButtonsMock.mockResolvedValueOnce({
ok: false,
wamid: null,
errorDetail:
'Send failed (HTTP 400): {"error":{"message":"(#131009) Parameter value is not valid","error_data":{"details":"Duplicate button title"}}}',
})
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: memberships(3) })
enqueue({ data: companies(3) })
enqueue({ data: [{ id: 'conv-1' }] })
const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 1,
})
expect(asked).toBe('asked')
expect(sendButtonsMock).toHaveBeenCalledTimes(1)
expect(sendTextMock).toHaveBeenCalledTimes(1)
const fallback = sendTextMock.mock.calls[0][1]
expect(fallback.template).toBe(TEMPLATE.m6CompanyQuestion)
expect(fallback.to).toBe('46701234567')
expect(fallback.body).toContain('1. Bolag A AB')
expect(fallback.body).toContain('3. Bolag C AB')
expect(fallback.body).toContain('Svara med en siffra')
// The question stays armed: exactly one conversation write, no rollback.
expect(findCalls('whatsapp_conversations', 'update')).toHaveLength(1)
})
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)' })
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: memberships(5) })
enqueue({ data: companies(5) })
enqueue({ data: [{ id: 'conv-1' }] })
const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 2,
})
expect(asked).toBe('asked')
expect(sendListMock).toHaveBeenCalledTimes(1)
expect(sendTextMock).toHaveBeenCalledTimes(1)
const body = sendTextMock.mock.calls[0][1].body
expect(body).toContain('kvittona')
expect(body).toContain('5. Bolag E AB')
expect(findCalls('whatsapp_conversations', 'update')).toHaveLength(1)
})
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)' })
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).toHaveBeenCalledTimes(1)
const updates = findCalls('whatsapp_conversations', 'update').map(
(args) => args[0] as { state?: string; context?: Record<string, unknown> },
)
expect(updates).toHaveLength(2)
expect(updates[0].state).toBe('awaiting_company')
expect(updates[1].state).toBe('idle')
expect(updates[1].context?.company_options).toBeUndefined()
expect(updates[1].context?.pending_question).toBeUndefined()
})
it('uses a list message for 4-10 companies', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: memberships(5) })
@@ -299,6 +407,11 @@ describe('applyCompanyChoice', () => {
stagedMessageIds: ['stg-1', 'stg-2'],
})
// The membership check is a LIVE-membership check: a tap or digit for a
// company archived since the question was asked is rejected (#1589).
expect(findCalls('company_members', 'select')[0][0]).toContain('companies!inner(archived_at)')
expect(findCalls('company_members', 'is')).toContainEqual(['companies.archived_at', null])
const patch = findCalls('whatsapp_conversations', 'update')[0][0] as {
state: string
company_id: string
@@ -427,3 +540,40 @@ describe('truncateTitle', () => {
expect(cut).not.toMatch(/\s…$/) // no dangling space before the ellipsis
})
})
describe('uniqueTitles', () => {
it('passes distinct short names through untouched', () => {
expect(uniqueTitles(['Bolag A AB', 'Bolag B AB', 'Bolag C AB'], 20)).toEqual([
'Bolag A AB',
'Bolag B AB',
'Bolag C AB',
])
})
it('disambiguates identical names with their 1-based position (Meta #131009)', () => {
const titles = uniqueTitles(['Capelix AB', 'Capelix AB'], 20)
expect(new Set(titles).size).toBe(2)
expect(titles[0]).toBe('Capelix AB 1')
expect(titles[1]).toBe('Capelix AB 2')
for (const t of titles) expect(t.length).toBeLessThanOrEqual(20)
})
it('disambiguates names that only collide after truncation, within the limit', () => {
const titles = uniqueTitles(
['Wennberg Fastighetsförvaltning AB', 'Wennberg Fastighetsförvaltning Holding AB'],
20,
)
expect(new Set(titles.map((t) => t.toLowerCase())).size).toBe(2)
for (const t of titles) {
expect(t.length).toBeLessThanOrEqual(20)
expect(t.startsWith('Wennberg')).toBe(true)
}
expect(titles[0].endsWith(' 1')).toBe(true)
expect(titles[1].endsWith(' 2')).toBe(true)
})
it('treats a case-only difference as a collision', () => {
const titles = uniqueTitles(['Bolag AB', 'bolag ab', 'Annat AB'], 24)
expect(titles).toEqual(['Bolag AB 1', 'bolag ab 2', 'Annat AB'])
})
})
@@ -3,6 +3,8 @@ import { createQueuedMockSupabase } from '@/tests/helpers'
import { TimeoutError } from '@/lib/http/fetch-with-timeout'
import {
sendText,
sendReplyButtons,
sendList,
sendReaction,
RECEIVED_REACTION_EMOJI,
downloadMedia,
@@ -98,6 +100,69 @@ describe('graph-api', () => {
})
})
describe('interactive titles are unique (#1589, Meta #131009 "Duplicate button title")', () => {
it('sendReplyButtons sends distinct titles when two options share a name, ids untouched', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ messages: [{ id: 'wamid.BTN' }] }), { status: 200 }),
)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null })
await sendReplyButtons(supabase as unknown as SupabaseClient, {
to: '46701234567',
body: 'Vilket företag gäller kvittot?',
template: TEMPLATE.m6CompanyQuestion,
buttons: [
{ id: 'company-live', title: 'Capelix AB' },
{ id: 'company-archived', title: 'Capelix AB' },
],
})
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
const payload = JSON.parse(init.body as string) as {
interactive: { action: { buttons: { reply: { id: string; title: string } }[] } }
}
const [first, second] = payload.interactive.action.buttons
expect(first.reply.title).not.toBe(second.reply.title)
expect(first.reply.title.length).toBeLessThanOrEqual(20)
expect(second.reply.title.length).toBeLessThanOrEqual(20)
expect(first.reply.id).toBe('company-live')
expect(second.reply.id).toBe('company-archived')
})
it('sendList sends distinct row titles when two options share a name, ids untouched', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ messages: [{ id: 'wamid.LIST' }] }), { status: 200 }),
)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null })
await sendList(supabase as unknown as SupabaseClient, {
to: '46701234567',
body: 'Vilket företag gäller kvittot?',
template: TEMPLATE.m6CompanyQuestion,
buttonLabel: 'Välj företag',
rows: [
{ id: 'c-1', title: 'Bolag A AB' },
{ id: 'c-2', title: 'Wennberg Fastighetsförvaltning AB' },
{ id: 'c-3', title: 'Wennberg Fastighetsförvaltning Holding AB' },
{ id: 'c-4', title: 'Bolag D AB' },
],
})
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
const payload = JSON.parse(init.body as string) as {
interactive: { action: { sections: { rows: { id: string; title: string }[] }[] } }
}
const rows = payload.interactive.action.sections[0].rows
expect(new Set(rows.map((r) => r.title.toLowerCase())).size).toBe(4)
for (const row of rows) expect(row.title.length).toBeLessThanOrEqual(24)
expect(rows.map((r) => r.id)).toEqual(['c-1', 'c-2', 'c-3', 'c-4'])
expect(rows[0].title).toBe('Bolag A AB')
expect(rows[3].title).toBe('Bolag D AB')
})
})
describe('sendReaction', () => {
it('posts a reaction payload targeting the inbound wamid', async () => {
fetchMock.mockResolvedValueOnce(
@@ -141,6 +141,9 @@ 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)' })
// 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)' })
const mock = createQueuedMockSupabase()
enqueueAsk(mock)
mock.enqueue({
@@ -162,6 +165,11 @@ describe('company question is not one-shot when the send fails', () => {
})
expect(asked).toBe('not_asked')
// The numbered fallback was tried once, with the same template id, before
// giving up.
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m6CompanyQuestion)
expect(sendTextMock.mock.calls[0][1].body).toContain('1. Bolag A AB')
const updates = mock
.findCalls('whatsapp_conversations', 'update')
.map((args) => args[0] as { state?: string; context?: Record<string, unknown> })
@@ -44,6 +44,14 @@ vi.mock('@/lib/core/documents/document-service', () => ({
computeSHA256: vi.fn().mockResolvedValue('sha-abc'),
}))
// The drain (#1589) re-kicks re-opened rows through the cookieless service
// client; route it to a per-test queued mock so the follow-up run is
// observable instead of hitting a real Supabase URL.
const kickClient = vi.hoisted(() => ({ current: null as unknown }))
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn(() => kickClient.current),
}))
import {
sendText,
markReadWithTyping,
@@ -177,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 parked behind an old company question
enqueue({ data: null }) // sha256 dup check: none
enqueue({ data: null }) // item channel_context load
enqueue({ data: null }) // item channel_context update
@@ -208,6 +217,11 @@ describe('processInboundMessage (media intake)', () => {
.channel_context
expect(contextArg.company_selected_via).toBe('single')
// "Sole membership" means sole LIVE membership (#1589): an archived
// same-named company must not turn this sender into a multi-company one.
expect(findCalls('company_members', 'select')[0][0]).toContain('companies!inner(archived_at)')
expect(findCalls('company_members', 'is')).toContainEqual(['companies.archived_at', null])
const finalUpdate = lastUpdate(findCalls)
expect(finalUpdate.processing_status).toBe('done')
expect(finalUpdate.inbox_item_id).toBe('item-1')
@@ -216,6 +230,164 @@ describe('processInboundMessage (media intake)', () => {
expect(sendTextMock).not.toHaveBeenCalled()
})
it('drains receipts parked behind an unaskable company question when the sender resolves as single', async () => {
const kick = createQueuedMockSupabase()
kickClient.current = kick.supabase
kick.enqueue({ data: null }) // follow-up loadRow for the re-opened row (gone: keep it inert)
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: 'stg-1' }, { id: 'stg-2' }] }) // drain: two rows were parked
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' })
// The guarded re-open targets exactly the staged marker on this conversation.
const reopen = findCalls('whatsapp_messages', 'update').find(
(args) =>
(args[0] as Record<string, unknown>).processing_status === 'received' &&
(args[0] as Record<string, unknown>).error_message === null,
)
expect(reopen).toBeTruthy()
const eqs = findCalls('whatsapp_messages', 'eq')
expect(eqs).toContainEqual(['error_message', STAGED_AWAITING_COMPANY])
expect(eqs).toContainEqual(['conversation_id', 'conv-1'])
// The re-opened rows were kicked: the follow-up run loaded the first one.
await new Promise((resolve) => setTimeout(resolve, 0))
expect(kick.findCalls('whatsapp_messages', 'eq')).toContainEqual(['id', 'stg-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() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink({ default_company_id: 'company-7' }) })
enqueue({ data: makeConversation() })
enqueue({ data: { company_id: 'company-7' } }) // membership check for default
enqueue({ data: null }) // dup check
enqueue({ data: null }) // item context load
enqueue({ data: null }) // item context update
enqueue({ data: null }) // markStatus done
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
// The default-membership check is also a LIVE-membership check.
expect(findCalls('company_members', 'is')).toContainEqual(['companies.archived_at', null])
const reopen = findCalls('whatsapp_messages', 'update').find(
(args) => (args[0] as Record<string, unknown>).processing_status === 'received',
)
expect(reopen).toBeUndefined()
})
it('clears the dead company question when the sender resolves as single (state, options, pending)', async () => {
const kick = createQueuedMockSupabase()
kickClient.current = kick.supabase
kick.enqueue({ data: null })
const deadQuestion = {
company_options: [
{ id: 'company-1', name: 'Alpha AB' },
{ id: 'company-2', name: 'Alpha AB' },
],
pending_question: { type: 'company', inbox_item_id: null, asked_at: '2026-08-01T09:30:00Z' },
budget: { day_key: '2026-08-01', count: 1 },
}
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() }) // load row
enqueue({ data: { id: 'msg-1' } }) // claim
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: [{ 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
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' })
// The conversation is released: idle, no options to re-offer (one of
// them is the archived company), no company pending_question, and the
// rest of the context (budget) untouched.
const clears = findCalls('whatsapp_conversations', 'update')
expect(clears).toHaveLength(1)
const patch = clears[0][0] as { state: string; context: Record<string, unknown> }
expect(patch.state).toBe('idle')
expect(patch.context).toEqual({ budget: deadQuestion.budget })
// Guarded on the revision it read, same idiom as every context write.
expect(findCalls('whatsapp_conversations', 'eq')).toContainEqual([
'updated_at',
'2026-08-01T09:00:00Z',
])
kickClient.current = null
})
it('clears company_options kept past the 48h TTL (idle conversation) once the sender resolves as single', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({
data: makeConversation({
state: 'idle',
context: { company_options: [{ id: 'company-1', name: 'A' }, { id: 'company-2', name: 'B' }] },
}),
})
enqueue({ data: [{ company_id: 'company-1' }] }) // sole live membership
enqueue({ data: [] }) // drain: the parked rows already expired
enqueue({ data: [makeConversation()] }) // guarded clear won
enqueue({ data: null }) // dup check
enqueue({ data: null }) // item context load
enqueue({ data: null }) // item context update
enqueue({ data: null }) // markStatus done
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
const clears = findCalls('whatsapp_conversations', 'update')
expect(clears).toHaveLength(1)
const patch = clears[0][0] as { state: string; context: Record<string, unknown> }
expect(patch.state).toBe('idle')
expect(patch.context).toEqual({})
})
it('leaves a representation question alone when the sender resolves as single', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({
data: makeConversation({
state: 'awaiting_representation',
context: {
pending_question: { type: 'representation', inbox_item_id: 'item-0', asked_at: '2026-08-01T09:30:00Z' },
},
}),
})
enqueue({ data: [{ company_id: 'company-1' }] }) // sole live membership
enqueue({ data: [] }) // drain: nothing parked
enqueue({ data: null }) // dup check
enqueue({ data: null }) // item context load
enqueue({ data: null }) // item context update
enqueue({ data: null }) // markStatus done
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
// Not a company question: nothing to clear, no conversation write.
expect(findCalls('whatsapp_conversations', 'update')).toHaveLength(0)
})
it('does nothing when the claim is lost (already processing)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
@@ -415,6 +587,7 @@ describe('processInboundMessage (media intake)', () => {
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: [] }) // drain: nothing parked behind an old company question
enqueue({ data: null }) // M17 notice check: none sent yet
enqueue({ data: null }) // markStatus skipped
@@ -438,6 +611,7 @@ describe('processInboundMessage (media intake)', () => {
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: [] }) // drain: nothing parked behind an old company question
enqueue({ data: { id: 'earlier-m17' } }) // notice already sent
enqueue({ data: null }) // markStatus skipped
@@ -453,6 +627,7 @@ describe('processInboundMessage (media intake)', () => {
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
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
enqueue({ data: null }) // markStatus skipped
@@ -491,6 +666,7 @@ describe('processInboundMessage (media intake)', () => {
}),
})
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: [] }) // drain: nothing parked behind an old company question
enqueue({ data: null }) // dup check
enqueue({ data: null }) // new item context load
enqueue({ data: null }) // new item context update
@@ -535,6 +711,7 @@ describe('processInboundMessage (media intake)', () => {
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: [] }) // drain: nothing parked behind an old company question
enqueue({ data: null }) // no inbox item yet
enqueue({ data: null }) // markStatus error
enqueue({ data: null }) // no M18 sent yet
@@ -559,6 +736,7 @@ describe('processInboundMessage (media intake)', () => {
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: [] }) // drain: nothing parked behind an old company question
enqueue({ data: null }) // no inbox item yet
enqueue({ data: null }) // markStatus error
enqueue({ data: null }) // no M18 sent for this message yet
@@ -578,6 +756,7 @@ describe('processInboundMessage (media intake)', () => {
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: [] }) // drain: nothing parked behind an old company question
enqueue({ data: null }) // no inbox item yet
enqueue({ data: null }) // markStatus error
enqueue({ data: { id: 'out-1' } }) // an M18 for this correlation exists
@@ -595,6 +774,7 @@ describe('processInboundMessage (media intake)', () => {
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: [] }) // drain: nothing parked behind an old company question
enqueue({ data: { id: 'item-winner' } }) // the winner's item
enqueue({ data: null }) // markStatus done
+5 -1
View File
@@ -268,10 +268,14 @@ async function handleUnknownSender(
processing_status: 'done',
})
// Non-archived only: the greeting must describe what the channel will
// then do (name the sole live company, or say it will ask), not count a
// company receipts can never be filed into (#1589).
const { data: memberships } = await supabase
.from('company_members')
.select('company_id')
.select('company_id, companies!inner(archived_at)')
.eq('user_id', consumed.userId)
.is('companies.archived_at', null)
const companyIds = [...new Set((memberships ?? []).map((m) => m.company_id as string))]
let companyName: string | null = null
@@ -21,6 +21,7 @@ import {
MAX_REPLY_BUTTONS,
MAX_LIST_ROWS,
type SendMessageBase,
type SendTextResult,
} from './graph-api'
import { botCopy, TEMPLATE } from './messages'
import {
@@ -51,10 +52,12 @@ export interface CompanyOption {
type ReplyBase = Omit<SendMessageBase, 'to' | 'template'>
/** All companies the linked user belongs to, alphabetical (stable digits).
* Returns null when a query FAILED: a transient DB error must read as
* "unknown", never as "no memberships", or the caller parks receipts behind
* a question that can never be asked. */
/** All non-archived companies the linked user belongs to, alphabetical
* (stable digits). An archived company is not a place receipts can go, and
* offering it produced a Meta-rejected payload when it shared its name with
* the live one (#1589). Returns null when a query FAILED: a transient DB
* error must read as "unknown", never as "no memberships", or the caller
* parks receipts behind a question that can never be asked. */
export async function loadCompanyOptions(
supabase: SupabaseClient,
userId: string,
@@ -74,6 +77,7 @@ export async function loadCompanyOptions(
.from('companies')
.select('id, name')
.in('id', companyIds)
.is('archived_at', null)
if (companiesError) {
log.warn('company options company query failed', { error: companiesError.message })
return null
@@ -107,6 +111,15 @@ export type AskCompanyQuestionOutcome = 'asked' | 'not_asked' | 'no_options' | '
* the rollback an expired token or a Graph 5xx left the conversation parked on
* a question the user never received, with the guard suppressing every later
* ask and the 48h TTL eventually discarding the staged receipts.
*
* Send ladder: reply buttons (<=3) or a list (<=10), then the numbered text
* variant when Meta rejects the interactive payload synchronously (#1589:
* HTTP 400 "Duplicate button title" and similar payload-shape errors are
* final for that payload, but plain text still delivers), and only when the
* text send ALSO fails is the question rolled back. The fallback reuses the
* M6 template id, so the audit/throttle keys are unchanged, and the answer
* path is mode-agnostic: a typed digit is accepted whenever company_options
* are open and is recorded as via='numbered'.
*/
export async function askCompanyQuestion(
supabase: SupabaseClient,
@@ -186,16 +199,21 @@ export async function askCompanyQuestion(
const copy = botCopy('sv')
const body = copy.m6CompanyQuestion({ count: args.stagedCount })
const numberedBody = copy.m6CompanyQuestionNumbered({
count: args.stagedCount,
options: options.map((o) => o.name),
})
const base = { to: args.to, template: TEMPLATE.m6CompanyQuestion, ...args.replyBase }
let sent: { ok: boolean }
const interactive = options.length <= MAX_LIST_ROWS
let sent: SendTextResult
if (options.length <= MAX_REPLY_BUTTONS) {
sent = await sendReplyButtons(supabase, {
...base,
body,
buttons: options.map((o) => ({ id: o.id, title: o.name })),
})
} else if (options.length <= MAX_LIST_ROWS) {
} else if (interactive) {
sent = await sendList(supabase, {
...base,
body,
@@ -203,13 +221,19 @@ export async function askCompanyQuestion(
rows: options.map((o) => ({ id: o.id, title: o.name })),
})
} else {
sent = await sendText(supabase, {
...base,
body: copy.m6CompanyQuestionNumbered({
count: args.stagedCount,
options: options.map((o) => o.name),
}),
sent = await sendText(supabase, { ...base, body: numberedBody })
}
if (!sent.ok && interactive) {
// 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).
log.warn('company question interactive send rejected; falling back to numbered text', {
conversationId: args.conversation.id,
errorDetail: sent.errorDetail,
})
sent = await sendText(supabase, { ...base, body: numberedBody })
}
if (!sent.ok) {
@@ -291,14 +315,17 @@ export async function applyCompanyChoice(
}
if (!companyId) return { ok: false, reason: 'invalid_option' }
// Defense in depth: the chosen company must be one the sender belongs to,
// whatever the payload claimed. A query ERROR is not a missing membership:
// treating a transient failure as "not a member" silently drops the answer.
// Defense in depth: the chosen company must be one the sender belongs to
// AND still be live, whatever the payload claimed (a stale tap can name a
// company archived since the question was asked). A query ERROR is not a
// missing membership: treating a transient failure as "not a member"
// silently drops the answer.
const { data: membership, error: membershipError } = await supabase
.from('company_members')
.select('company_id')
.select('company_id, companies!inner(archived_at)')
.eq('user_id', args.link.user_id)
.eq('company_id', companyId)
.is('companies.archived_at', null)
.limit(1)
.maybeSingle()
if (membershipError) {
@@ -198,6 +198,30 @@ export function truncateTitle(name: string, max: number): string {
return `${cut.trimEnd()}…`
}
/**
* Truncate every title to `max` and make the results unique. Meta rejects an
* interactive payload whose buttons or rows share a title (HTTP 400, error
* #131009 "Duplicate button title"), which happens when a sender belongs to
* two same-named companies or when two long names truncate to the same
* prefix. Colliding entries (compared case-insensitively) get their 1-based
* position appended: the same digit the numbered text variant and the
* typed-digit answer path use, so a "Bolag AB 2" button and a "2" reply mean
* the same option. The result never exceeds `max`.
*/
export function uniqueTitles(titles: string[], max: number): string[] {
const truncated = titles.map((t) => truncateTitle(t, max))
const occurrences = new Map<string, number>()
for (const title of truncated) {
const key = title.toLowerCase()
occurrences.set(key, (occurrences.get(key) ?? 0) + 1)
}
return truncated.map((title, i) => {
if ((occurrences.get(title.toLowerCase()) ?? 0) < 2) return title
const suffix = ` ${i + 1}`
return `${truncateTitle(titles[i], max - suffix.length)}${suffix}`
})
}
export interface SendReplyButtonsArgs extends SendMessageBase {
body: string
/** At most MAX_REPLY_BUTTONS options; extras are dropped defensively. */
@@ -212,6 +236,10 @@ export async function sendReplyButtons(
args: SendReplyButtonsArgs,
): Promise<SendTextResult> {
const buttons = args.buttons.slice(0, MAX_REPLY_BUTTONS)
const titles = uniqueTitles(
buttons.map((b) => b.title),
BUTTON_TITLE_MAX,
)
const result = await postToGraph(
{
messaging_product: 'whatsapp',
@@ -222,9 +250,9 @@ export async function sendReplyButtons(
type: 'button',
body: { text: args.body },
action: {
buttons: buttons.map((b) => ({
buttons: buttons.map((b, i) => ({
type: 'reply',
reply: { id: b.id, title: truncateTitle(b.title, BUTTON_TITLE_MAX) },
reply: { id: b.id, title: titles[i] },
})),
},
},
@@ -259,6 +287,10 @@ export async function sendList(
args: SendListArgs,
): Promise<SendTextResult> {
const rows = args.rows.slice(0, MAX_LIST_ROWS)
const titles = uniqueTitles(
rows.map((r) => r.title),
LIST_ROW_TITLE_MAX,
)
const result = await postToGraph(
{
messaging_product: 'whatsapp',
@@ -272,9 +304,9 @@ export async function sendList(
button: truncateTitle(args.buttonLabel, BUTTON_TITLE_MAX),
sections: [
{
rows: rows.map((r) => ({
rows: rows.map((r, i) => ({
id: r.id,
title: truncateTitle(r.title, LIST_ROW_TITLE_MAX),
title: titles[i],
})),
},
],
@@ -147,6 +147,8 @@ async function loadLink(
return (data as WhatsAppPhoneLink | null) ?? null
}
/** Live membership: a pin or default pointing at an ARCHIVED company must not
* receive receipts (same filter shape as lib/supabase/middleware.ts). */
async function isMember(
supabase: SupabaseClient,
userId: string,
@@ -154,9 +156,10 @@ async function isMember(
): Promise<boolean> {
const { data } = await supabase
.from('company_members')
.select('company_id')
.select('company_id, companies!inner(archived_at)')
.eq('user_id', userId)
.eq('company_id', companyId)
.is('companies.archived_at', null)
.limit(1)
.maybeSingle()
return data != null
@@ -275,10 +278,31 @@ interface ResolvedCompany {
via: NonNullable<InboxChannelContext['company_selected_via']>
}
/**
* True when a company question is still open on the conversation, in any of
* the shapes it survives in: the awaiting_company state, the options behind a
* digit answer (kept past the 48h TTL for a late reply), or the company
* pending_question. Once the sender resolves as 'single' none of them can be
* answered any more.
*/
function hasDeadCompanyQuestion(conversation: WhatsAppConversation): boolean {
const context = getContext(conversation)
return (
conversation.state === 'awaiting_company' ||
(context.company_options?.length ?? 0) > 0 ||
context.pending_question?.type === 'company'
)
}
/**
* Conversation pin (live + still a member, sliding 8h) -> default company
* (still a member) -> sole membership -> null (ask).
*
* Every membership here means a NON-ARCHIVED company: an archived one is not
* a place receipts can go, and counting it made a sender with one live
* company look multi-company (#1589), so they were asked a question Meta
* refused to deliver.
*
* Returns 'transient_error' when the membership query FAILED: a DB blip must
* not read as "no memberships", which used to park the rows behind an
* unanswerable company question forever. The caller releases the row so the
@@ -318,8 +342,9 @@ async function resolveCompanyTarget(
const { data: memberships, error: membershipsError } = await supabase
.from('company_members')
.select('company_id')
.select('company_id, companies!inner(archived_at)')
.eq('user_id', link.user_id)
.is('companies.archived_at', null)
if (membershipsError) {
log.warn('membership query failed during company resolution; will retry', {
error: membershipsError.message,
@@ -512,6 +537,55 @@ async function processMediaMessage(
}
const companyId = resolved.companyId
if (resolved.via === 'single' && conversation) {
// Receipts parked behind an earlier company question can never be
// 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) {
log.info('re-opened receipts parked behind an unaskable company question', {
conversationId: conversation.id,
count: parkedIds.length,
})
kickInboundProcessing(parkedIds)
}
// 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
// finalizeBurst from asking anything about the drained receipts until
// the 48h TTL sweep. Guarded and re-checked against fresh state, so a
// concurrent worker that already cleared it is a no-op, and only the
// company question is touched: a representation/context question that
// opened in between stays.
if (hasDeadCompanyQuestion(conversation)) {
await updateConversation(supabase, conversation, (current, currentContext) => {
if (!hasDeadCompanyQuestion(current)) return null
const nextContext: ConversationContext = { ...currentContext }
delete nextContext.company_options
if (nextContext.pending_question?.type === 'company') delete nextContext.pending_question
return {
state: current.state === 'awaiting_company' ? 'idle' : current.state,
context: nextContext,
}
})
log.info('cleared the dead company question on a single-company conversation', {
conversationId: conversation.id,
})
}
}
// ── Per-company intake quota (ack-and-drop, never retryable) ──
const limit = await checkInboxUploadRateLimit(supabase, companyId)
if (!limit.ok) {